diff --git a/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/settings/AccountIdSetting.java b/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/settings/AccountIdSetting.java new file mode 100644 index 000000000..7baa30f04 --- /dev/null +++ b/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/settings/AccountIdSetting.java @@ -0,0 +1,29 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.client.core.settings; + +import software.amazon.smithy.java.client.core.ClientSetting; +import software.amazon.smithy.java.context.Context; + +/** + * Configures an AWS Account ID. + */ +public interface AccountIdSetting> extends RegionSetting { + /** + * AWS Account ID to use. + */ + Context.Key AWS_ACCOUNT_ID = Context.key("AWS Account ID"); + + /** + * Sets the AWS Account ID. + * + * @param awsAccountId AWS account ID to set. + * @return self + */ + default B awsAccountId(String awsAccountId) { + return putConfig(AWS_ACCOUNT_ID, awsAccountId); + } +} diff --git a/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/settings/EndpointSettings.java b/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/settings/EndpointSettings.java new file mode 100644 index 000000000..5f8a00eb6 --- /dev/null +++ b/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/settings/EndpointSettings.java @@ -0,0 +1,59 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.client.core.settings; + +import software.amazon.smithy.java.client.core.ClientSetting; +import software.amazon.smithy.java.context.Context; + +/** + * Configures AWS specific endpoint settings. + */ +public interface EndpointSettings> extends RegionSetting, AccountIdSetting { + /** + * If the SDK client is configured to use dual stack endpoints, defaults to false. + */ + Context.Key USE_DUAL_STACK = Context.key("Whether to use dual stack endpoint"); + + /** + * If the SDK client is configured to use FIPS-compliant endpoints, defaults to false. + */ + Context.Key USE_FIPS = Context.key("Whether to use FIPS endpoints"); + + /** + * The mode used when resolving Account ID based endpoints. + */ + Context.Key ACCOUNT_ID_ENDPOINT_MODE = Context.key("Account ID endpoint mode"); + + /** + * Configures if the SDK uses dual stack endpoints. Defaults to false. + * + * @param useDualStack True to enable dual stack. + * @return self + */ + default B useDualStackEndpoint(boolean useDualStack) { + return putConfig(USE_DUAL_STACK, useDualStack); + } + + /** + * Configures if the SDK uses FIPS endpoints. Defaults to false. + * + * @param useFips True to enable FIPS endpoints. + * @return self + */ + default B useFipsEndpoint(boolean useFips) { + return putConfig(USE_FIPS, useFips); + } + + /** + * Sets the account ID endpoint mode for endpoint resolution. + * + * @param accountIdEndpointMode Account ID based endpoint resolution mode. + * @return self + */ + default B accountIdEndpointMode(String accountIdEndpointMode) { + return putConfig(ACCOUNT_ID_ENDPOINT_MODE, accountIdEndpointMode); + } +} diff --git a/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/settings/S3EndpointSettings.java b/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/settings/S3EndpointSettings.java new file mode 100644 index 000000000..0746aed53 --- /dev/null +++ b/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/settings/S3EndpointSettings.java @@ -0,0 +1,110 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.client.core.settings; + +import software.amazon.smithy.java.client.core.ClientSetting; +import software.amazon.smithy.java.context.Context; + +/** + * Configures AWS specific endpoint settings. + */ +public interface S3EndpointSettings> extends EndpointSettings { + /** + * If the SDK client is configured to use S3 transfer acceleration, defaults to false. + */ + Context.Key S3_ACCELERATE = Context.key("AWS::S3::Accelerate"); + + /** + * If the SDK client is configured to not use S3's multi-region access points, defaults to false. + */ + Context.Key S3_DISABLE_MULTI_REGION_ACCESS_POINTS = Context.key("AWS::S3::DisableMultiRegionAccessPoints"); + + /** + * If the SDK client is configured to use solely S3 path style routing, defaults to false. + */ + Context.Key S3_FORCE_PATH_STYLE = Context.key("AWS::S3::ForcePathStyle"); + + /** + * If the SDK client is configured to use S3 bucket ARN regions or raise an error when the bucket ARN and client + * region differ, defaults to true. + */ + Context.Key S3_USE_ARN_REGION = Context.key("AWS::S3::UseArnRegion"); + + /** + * If the SDK client is configured to use S3's global endpoint instead of the regional us-east-1 endpoint, + * defaults to false. + */ + Context.Key S3_USE_GLOBAL_ENDPOINT = Context.key("AWS::S3::UseGlobalEndpoint"); + + /** + * If the SDK client is configured to use S3 Control bucket ARN regions or raise an error when the bucket ARN + * and client region differ, defaults to true. + */ + Context.Key S3_CONTROL_USE_ARN_REGION = Context.key("AWS::S3Control::UseArnRegion"); + + /** + * Configures if the SDK client is configured to use S3 transfer acceleration, defaults to false. + * + * @param useS3Accelerate True to enable. + * @return self + */ + default B s3useAccelerate(boolean useS3Accelerate) { + return putConfig(S3_ACCELERATE, useS3Accelerate); + } + + /** + * If the SDK client is configured to not use S3's multi-region access points, defaults to false. + * + * @param value True to disable MRAP. + * @return self + */ + default B s3disableMultiRegionAccessPoints(boolean value) { + return putConfig(S3_DISABLE_MULTI_REGION_ACCESS_POINTS, value); + } + + /** + * If the SDK client is configured to use solely S3 path style routing, defaults to false. + * + * @param usePathStyle True to force path style. + * @return self + */ + default B s3forcePathStyle(boolean usePathStyle) { + return putConfig(S3_FORCE_PATH_STYLE, usePathStyle); + } + + /** + * If the SDK client is configured to use S3 bucket ARN regions or raise an error when the bucket ARN and client + * region differ, defaults to true. + * + * @param useArnRegion True to use ARN region. + * @return self + */ + default B s3useArnRegion(boolean useArnRegion) { + return putConfig(S3_USE_ARN_REGION, useArnRegion); + } + + /** + * If the SDK client is configured to use S3's global endpoint instead of the regional us-east-1 endpoint, + * defaults to false. + * + * @param useGlobalEndpoint True to enable global endpoint. + * @return self + */ + default B s3useGlobalEndpoint(boolean useGlobalEndpoint) { + return putConfig(S3_USE_GLOBAL_ENDPOINT, useGlobalEndpoint); + } + + /** + * If the SDK client is configured to use S3 Control bucket ARN regions or raise an error when the bucket ARN + * and client region differ, defaults to true. + * + * @param useArnRegion True to enable S3 control ARN region. + * @return self + */ + default B s3controlUseArnRegion(boolean useArnRegion) { + return putConfig(S3_CONTROL_USE_ARN_REGION, useArnRegion); + } +} diff --git a/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/settings/StsEndpointSettings.java b/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/settings/StsEndpointSettings.java new file mode 100644 index 000000000..a0d0ef09e --- /dev/null +++ b/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/settings/StsEndpointSettings.java @@ -0,0 +1,31 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.client.core.settings; + +import software.amazon.smithy.java.client.core.ClientSetting; +import software.amazon.smithy.java.context.Context; + +/** + * Configures AWS specific endpoint settings. + */ +public interface StsEndpointSettings> extends EndpointSettings { + /** + * If the SDK client is configured to use STS' global endpoint instead of the regional us-east-1 endpoint, + * defaults to false. + */ + Context.Key STS_USE_GLOBAL_ENDPOINT = Context.key("AWS::STS::UseGlobalEndpoint"); + + /** + * Configures if if the SDK client is configured to use STS' global endpoint instead of the regional us-east-1 + * endpoint, defaults to false. + * + * @param useGlobalEndpoint True to enable global endpoints. + * @return self + */ + default B stsUseGlobalEndpoint(boolean useGlobalEndpoint) { + return putConfig(STS_USE_GLOBAL_ENDPOINT, useGlobalEndpoint); + } +} diff --git a/aws/client/aws-client-rulesengine/build.gradle.kts b/aws/client/aws-client-rulesengine/build.gradle.kts new file mode 100644 index 000000000..fc4df8f98 --- /dev/null +++ b/aws/client/aws-client-rulesengine/build.gradle.kts @@ -0,0 +1,14 @@ +plugins { + id("smithy-java.module-conventions") +} + +description = "This module provides AWS-Specific client rules engine functionality" + +extra["displayName"] = "Smithy :: Java :: AWS :: Client :: Rules Engine" +extra["moduleName"] = "software.amazon.smithy.java.aws.client.rulesengine" + +dependencies { + api(project(":aws:client:aws-client-core")) + api(project(":client:client-rulesengine")) + api(libs.smithy.aws.endpoints) +} diff --git a/aws/client/aws-client-rulesengine/src/main/java/software/amazon/smithy/java/aws/client/rulesengine/AwsRulesBuiltin.java b/aws/client/aws-client-rulesengine/src/main/java/software/amazon/smithy/java/aws/client/rulesengine/AwsRulesBuiltin.java new file mode 100644 index 000000000..e5ecb1f45 --- /dev/null +++ b/aws/client/aws-client-rulesengine/src/main/java/software/amazon/smithy/java/aws/client/rulesengine/AwsRulesBuiltin.java @@ -0,0 +1,154 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.client.rulesengine; + +import java.util.HashMap; +import java.util.Map; +import java.util.function.BiFunction; +import java.util.function.Function; +import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsIdentity; +import software.amazon.smithy.java.aws.client.core.settings.AccountIdSetting; +import software.amazon.smithy.java.aws.client.core.settings.EndpointSettings; +import software.amazon.smithy.java.aws.client.core.settings.RegionSetting; +import software.amazon.smithy.java.aws.client.core.settings.S3EndpointSettings; +import software.amazon.smithy.java.aws.client.core.settings.StsEndpointSettings; +import software.amazon.smithy.java.client.core.CallContext; +import software.amazon.smithy.java.context.Context; + +/** + * AWS built-ins. + * + * @link AWS built-ins + */ +enum AwsRulesBuiltin implements Function { + REGION("AWS::Region") { + @Override + public Object apply(Context ctx) { + return ctx.get(RegionSetting.REGION); + } + }, + + USE_DUAL_STACK("AWS::UseDualStack") { + @Override + public Object apply(Context ctx) { + return ctx.get(EndpointSettings.USE_DUAL_STACK); + } + }, + + USE_FIPS("AWS::UseFIPS") { + @Override + public Object apply(Context ctx) { + return ctx.get(EndpointSettings.USE_FIPS); + } + }, + + AWS_AUTH_ACCOUNT_ID("AWS::Auth::AccountId") { + @Override + public Object apply(Context ctx) { + var result = ctx.get(AccountIdSetting.AWS_ACCOUNT_ID); + if (result != null) { + return result; + } + if (ctx.get(CallContext.IDENTITY) instanceof AwsCredentialsIdentity awsIdentity) { + return awsIdentity.accountId(); + } + return null; + } + }, + + AWS_AUTH_ACCOUNT_ID_ENDPOINT_MODE("AWS::Auth::AccountIdEndpointMode") { + @Override + public Object apply(Context ctx) { + return ctx.get(EndpointSettings.ACCOUNT_ID_ENDPOINT_MODE); + } + }, + + AWS_AUTH_CREDENTIAL_SCOPE("AWS::Auth::CredentialScope") { + @Override + public Object apply(Context ctx) { + // TODO + throw new UnsupportedOperationException("Not yet implemented: " + this); + } + }, + + AWS_S3_ACCELERATE("AWS::S3::Accelerate") { + @Override + public Object apply(Context context) { + return context.get(S3EndpointSettings.S3_ACCELERATE); + } + }, + + AWS_S3_DISABLE_MULTI_REGION_ACCESS_POINTS("AWS::S3::DisableMultiRegionAccessPoints") { + @Override + public Object apply(Context context) { + return context.get(S3EndpointSettings.S3_DISABLE_MULTI_REGION_ACCESS_POINTS); + } + }, + + AWS_S3_FORCE_PATH_STYLE("AWS::S3::ForcePathStyle") { + @Override + public Object apply(Context context) { + return context.get(S3EndpointSettings.S3_FORCE_PATH_STYLE); + } + }, + + AWS_S3_USE_ARN_REGION("AWS::S3::UseArnRegion") { + @Override + public Object apply(Context context) { + return context.get(S3EndpointSettings.S3_USE_ARN_REGION); + } + }, + + AWS_S3_USE_GLOBAL_ENDPOINT("AWS::S3::UseGlobalEndpoint") { + @Override + public Object apply(Context context) { + return context.get(S3EndpointSettings.S3_USE_GLOBAL_ENDPOINT); + } + }, + + AWS_S3_CONTROL_USE_ARN_REGION("AWS::S3Control::UseArnRegion") { + @Override + public Object apply(Context context) { + return context.get(S3EndpointSettings.S3_CONTROL_USE_ARN_REGION); + } + }, + + AWS_STS_USE_GLOBAL_ENDPOINT("AWS::STS::UseGlobalEndpoint") { + @Override + public Object apply(Context context) { + return context.get(StsEndpointSettings.STS_USE_GLOBAL_ENDPOINT); + } + }; + + static final BiFunction BUILTIN_PROVIDER = new BuiltinProvider(); + + private static final class BuiltinProvider implements BiFunction { + private final Map> providers = new HashMap<>(); + + private BuiltinProvider() { + for (var e : values()) { + providers.put(e.name, e); + } + } + + @Override + public Object apply(String name, Context context) { + var match = providers.get(name); + return match == null ? null : match.apply(context); + } + } + + private final String name; + + AwsRulesBuiltin(String name) { + this.name = name; + } + + @Override + public String toString() { + return name; + } +} diff --git a/aws/client/aws-client-rulesengine/src/main/java/software/amazon/smithy/java/aws/client/rulesengine/AwsRulesExtension.java b/aws/client/aws-client-rulesengine/src/main/java/software/amazon/smithy/java/aws/client/rulesengine/AwsRulesExtension.java new file mode 100644 index 000000000..92210ebcb --- /dev/null +++ b/aws/client/aws-client-rulesengine/src/main/java/software/amazon/smithy/java/aws/client/rulesengine/AwsRulesExtension.java @@ -0,0 +1,32 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.client.rulesengine; + +import java.util.Arrays; +import java.util.List; +import java.util.function.BiFunction; +import software.amazon.smithy.java.client.rulesengine.RulesExtension; +import software.amazon.smithy.java.client.rulesengine.RulesFunction; +import software.amazon.smithy.java.context.Context; +import software.amazon.smithy.utils.SmithyUnstableApi; + +/** + * Adds AWS-specific functionality to the Smithy Rules engines, used to resolve endpoints. + * + * @link AWS rules engine extensions + */ +@SmithyUnstableApi +public class AwsRulesExtension implements RulesExtension { + @Override + public BiFunction getBuiltinProvider() { + return AwsRulesBuiltin.BUILTIN_PROVIDER; + } + + @Override + public List getFunctions() { + return Arrays.asList(AwsRulesFunction.values()); + } +} diff --git a/aws/client/aws-client-rulesengine/src/main/java/software/amazon/smithy/java/aws/client/rulesengine/AwsRulesFunction.java b/aws/client/aws-client-rulesengine/src/main/java/software/amazon/smithy/java/aws/client/rulesengine/AwsRulesFunction.java new file mode 100644 index 000000000..b23abf36e --- /dev/null +++ b/aws/client/aws-client-rulesengine/src/main/java/software/amazon/smithy/java/aws/client/rulesengine/AwsRulesFunction.java @@ -0,0 +1,139 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.client.rulesengine; + +import java.util.AbstractMap; +import java.util.Map; +import java.util.Set; +import software.amazon.smithy.java.client.rulesengine.RulesFunction; +import software.amazon.smithy.rulesengine.aws.language.functions.AwsArn; +import software.amazon.smithy.rulesengine.aws.language.functions.AwsPartition; +import software.amazon.smithy.rulesengine.aws.language.functions.IsVirtualHostableS3Bucket; +import software.amazon.smithy.rulesengine.aws.language.functions.ParseArn; +import software.amazon.smithy.rulesengine.aws.language.functions.partition.Partition; + +/** + * Implements AWS rules engine functions. + * + * @link AWS rules engine functions + */ +enum AwsRulesFunction implements RulesFunction { + AWS_PARTITION("aws.partition", 1) { + @Override + public Object apply1(Object arg1) { + String region = (String) arg1; + var partition = AwsPartition.findPartition(region); + if (partition == null) { + return null; + } + return new PartitionMap(partition); + } + + // Convert AwsPartition to the map structure used in the rules engine. + // Most of the entries aren't needed for evaluating rules, so map entries are created lazily (something that + // isn't needed when evaluating rules), and accessing map values is done using a switch (something that is + // essentially compiled into a map lookup via a lookupswitch). + private static final class PartitionMap extends AbstractMap { + private final Partition partition; + private Set> entrySet; + + private PartitionMap(Partition partition) { + this.partition = partition; + } + + @Override + public int size() { + return 6; + } + + @Override + public Set> entrySet() { + var result = entrySet; + if (result == null) { + result = Set.of( + Map.entry("name", partition.getId()), + Map.entry("dnsSuffix", partition.getOutputs().getDnsSuffix()), + Map.entry("dualStackDnsSuffix", partition.getOutputs().getDualStackDnsSuffix()), + Map.entry("supportsFIPS", partition.getOutputs().supportsFips()), + Map.entry("supportsDualStack", partition.getOutputs().supportsDualStack()), + Map.entry("implicitGlobalRegion", partition.getOutputs().getImplicitGlobalRegion())); + entrySet = result; + } + return result; + } + + @Override + public Object get(Object key) { + if (key instanceof String s) { + return switch (s) { + case "name" -> partition.getId(); + case "dnsSuffix" -> partition.getOutputs().getDnsSuffix(); + case "dualStackDnsSuffix" -> partition.getOutputs().getDualStackDnsSuffix(); + case "supportsFIPS" -> partition.getOutputs().supportsFips(); + case "supportsDualStack" -> partition.getOutputs().supportsDualStack(); + case "implicitGlobalRegion" -> partition.getOutputs().getImplicitGlobalRegion(); + default -> null; + }; + } + return null; + } + } + }, + + AWS_PARSE_ARN("aws.parseArn", 1) { + @Override + public Object apply1(Object arg1) { + String value = (String) arg1; + var awsArn = AwsArn.parse(value).orElse(null); + if (awsArn == null) { + return null; + } + return Map.of( + ParseArn.PARTITION, + awsArn.getPartition(), + ParseArn.SERVICE, + awsArn.getService(), + ParseArn.REGION, + awsArn.getRegion(), + ParseArn.ACCOUNT_ID, + awsArn.getAccountId(), + "resourceId", // TODO: make this one public too in Smithy + awsArn.getResource()); + } + }, + + AWS_IS_VIRTUAL_HOSTED_BUCKET("aws.isVirtualHostableS3Bucket", 2) { + @Override + public Object apply2(Object arg1, Object arg2) { + var hostLabel = (String) arg1; + var allowDots = arg2 != null && (boolean) arg2; + return IsVirtualHostableS3Bucket.isVirtualHostableBucket(hostLabel, allowDots); + } + }; + + private final String name; + private final int operands; + + AwsRulesFunction(String name, int operands) { + this.name = name; + this.operands = operands; + } + + @Override + public String toString() { + return name; + } + + @Override + public int getOperandCount() { + return operands; + } + + @Override + public String getFunctionName() { + return name; + } +} diff --git a/aws/client/aws-client-rulesengine/src/main/resources/META-INF/resources/software.amazon.smithy.java.client.rulesengine.RulesExtension b/aws/client/aws-client-rulesengine/src/main/resources/META-INF/resources/software.amazon.smithy.java.client.rulesengine.RulesExtension new file mode 100644 index 000000000..4f2018b5e --- /dev/null +++ b/aws/client/aws-client-rulesengine/src/main/resources/META-INF/resources/software.amazon.smithy.java.client.rulesengine.RulesExtension @@ -0,0 +1 @@ +software.amazon.smithy.java.aws.client.rulesengine.AwsRulesExtension diff --git a/aws/client/aws-client-rulesengine/src/test/java/software/amazon/smithy/java/aws/client/rulesengine/RunnerTest.java b/aws/client/aws-client-rulesengine/src/test/java/software/amazon/smithy/java/aws/client/rulesengine/RunnerTest.java new file mode 100644 index 000000000..3c8295570 --- /dev/null +++ b/aws/client/aws-client-rulesengine/src/test/java/software/amazon/smithy/java/aws/client/rulesengine/RunnerTest.java @@ -0,0 +1,10 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.client.rulesengine; + +public class RunnerTest { + // TODO +} diff --git a/build.gradle.kts b/build.gradle.kts index 8c4d2ded9..5beb9d2fa 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,6 +7,11 @@ plugins { alias(libs.plugins.jreleaser) } +repositories { + mavenLocal() + mavenCentral() +} + task("addGitHooks") { onlyIf("unix") { !Os.isFamily(Os.FAMILY_WINDOWS) diff --git a/buildSrc/src/main/kotlin/smithy-java.module-conventions.gradle.kts b/buildSrc/src/main/kotlin/smithy-java.module-conventions.gradle.kts index e016efe8b..4e859738d 100644 --- a/buildSrc/src/main/kotlin/smithy-java.module-conventions.gradle.kts +++ b/buildSrc/src/main/kotlin/smithy-java.module-conventions.gradle.kts @@ -49,7 +49,6 @@ afterEvaluate { manifest { attributes(mapOf("Automatic-Module-Name" to moduleName)) } - duplicatesStrategy = DuplicatesStrategy.FAIL } } diff --git a/client/client-core/src/main/java/software/amazon/smithy/java/client/core/CallContext.java b/client/client-core/src/main/java/software/amazon/smithy/java/client/core/CallContext.java index fa440f368..ca7e12b6f 100644 --- a/client/client-core/src/main/java/software/amazon/smithy/java/client/core/CallContext.java +++ b/client/client-core/src/main/java/software/amazon/smithy/java/client/core/CallContext.java @@ -5,7 +5,6 @@ package software.amazon.smithy.java.client.core; -import java.time.Duration; import java.util.HashSet; import java.util.Set; import software.amazon.smithy.java.auth.api.identity.Identity; @@ -15,30 +14,26 @@ /** * Context parameters made available to underlying transports like HTTP clients. + * + *

Settings that can be applied client-wide can be found in {@link ClientContext}. */ public final class CallContext { /** - * The total amount of time to wait for an API call to complete, including retries, and serialization. + * The read-only endpoint for the request. */ - public static final Context.Key API_CALL_TIMEOUT = Context.key("API call timeout"); - - /** - * The amount of time to wait for a single, underlying network request to complete before giving up and timing out. - */ - public static final Context.Key API_CALL_ATTEMPT_TIMEOUT = Context.key("API call attempt timeout"); + public static final Context.Key ENDPOINT = Context.key("Endpoint of the request"); /** * The endpoint resolver used to resolve the destination endpoint for a request. + * + *

This is a read-only value; modifying this value has no effect on a request. */ public static final Context.Key ENDPOINT_RESOLVER = Context.key("EndpointResolver"); /** - * The read-only resolved endpoint for the request. - */ - public static final Context.Key ENDPOINT = Context.key("Endpoint of the request"); - - /** - * The identity resolved for the request. + * The read-only identity resolved for the request. + * + *

This is a read-only value; modifying this value has no effect on a request. */ public static final Context.Key IDENTITY = Context.key("Identity of the caller"); @@ -74,16 +69,5 @@ public final class CallContext { "Feature IDs used with a request", HashSet::new); - /** - * The name of the application, used in things like user-agent headers. - * - *

This value is used by AWS SDKs, but can be used generically for any client. - * See Application ID for more - * information. - * - *

This value should be less than 50 characters. - */ - public static final Context.Key APPLICATION_ID = Context.key("Application ID"); - private CallContext() {} } diff --git a/client/client-core/src/main/java/software/amazon/smithy/java/client/core/Client.java b/client/client-core/src/main/java/software/amazon/smithy/java/client/core/Client.java index 03c751551..46d816be3 100644 --- a/client/client-core/src/main/java/software/amazon/smithy/java/client/core/Client.java +++ b/client/client-core/src/main/java/software/amazon/smithy/java/client/core/Client.java @@ -13,6 +13,7 @@ import software.amazon.smithy.java.auth.api.identity.IdentityResolvers; import software.amazon.smithy.java.client.core.auth.scheme.AuthScheme; import software.amazon.smithy.java.client.core.auth.scheme.AuthSchemeResolver; +import software.amazon.smithy.java.client.core.endpoint.Endpoint; import software.amazon.smithy.java.client.core.endpoint.EndpointResolver; import software.amazon.smithy.java.client.core.interceptors.CallHook; import software.amazon.smithy.java.client.core.interceptors.ClientInterceptor; @@ -224,6 +225,34 @@ public B endpointResolver(EndpointResolver endpointResolver) { return (B) this; } + /** + * Set a custom endpoint for the client to use. + * + *

Note that things like "hostLabel" traits may still cause the endpoint to change. For a completely + * static endpoint that never changes, use {@link EndpointResolver#staticHost}. + * + * @param customEndpoint Endpoint to use with the client. + * @return the builder. + */ + @SuppressWarnings("unchecked") + public B endpoint(Endpoint customEndpoint) { + putConfig(ClientContext.CUSTOM_ENDPOINT, customEndpoint); + return (B) this; + } + + /** + * Set a custom endpoint for the client to use. + * + * @param customEndpoint Endpoint to use with the client. + * @return the builder. + * @see #endpoint(Endpoint) + */ + @SuppressWarnings("unchecked") + public B endpoint(String customEndpoint) { + putConfig(ClientContext.CUSTOM_ENDPOINT, Endpoint.builder().uri(customEndpoint).build()); + return (B) this; + } + /** * Add an interceptor to the client. * diff --git a/client/client-core/src/main/java/software/amazon/smithy/java/client/core/ClientCall.java b/client/client-core/src/main/java/software/amazon/smithy/java/client/core/ClientCall.java index 1270880e4..c61828960 100644 --- a/client/client-core/src/main/java/software/amazon/smithy/java/client/core/ClientCall.java +++ b/client/client-core/src/main/java/software/amazon/smithy/java/client/core/ClientCall.java @@ -64,6 +64,8 @@ private ClientCall(Builder builder) { supportedAuthSchemes = builder.supportedAuthSchemes.stream() .collect(Collectors.toMap(AuthScheme::schemeId, Function.identity(), (key1, key2) -> key1)); + context.put(CallContext.ENDPOINT_RESOLVER, endpointResolver); + // Retries retryStrategy = Objects.requireNonNull(builder.retryStrategy, "retryStrategy is null"); retryScope = Objects.requireNonNullElse(builder.retryScope, ""); diff --git a/client/client-core/src/main/java/software/amazon/smithy/java/client/core/ClientConfig.java b/client/client-core/src/main/java/software/amazon/smithy/java/client/core/ClientConfig.java index 110ebd0b8..e34cd6b33 100644 --- a/client/client-core/src/main/java/software/amazon/smithy/java/client/core/ClientConfig.java +++ b/client/client-core/src/main/java/software/amazon/smithy/java/client/core/ClientConfig.java @@ -70,7 +70,19 @@ private ClientConfig(Builder builder) { this.transport = builder.transport; ClientPipeline.validateProtocolAndTransport(protocol, transport); - this.endpointResolver = Objects.requireNonNull(builder.endpointResolver, "endpointResolver is null"); + // Use an explicitly given resolver if one was set. + if (builder.endpointResolver != null) { + this.endpointResolver = builder.endpointResolver; + } else { + // Use a custom endpoint and static endpoint resolver if a custom endpoint was given. + // Things like the Smithy rules engine based resolver look for this property to know if a custom endpoint + // was provided in this manner. + var customEndpoint = builder.context.get(ClientContext.CUSTOM_ENDPOINT); + if (customEndpoint == null) { + throw new NullPointerException("Both endpointResolver and ClientContext.CUSTOM_ENDPOINT are not set"); + } + this.endpointResolver = EndpointResolver.staticEndpoint(customEndpoint); + } this.interceptors = List.copyOf(builder.interceptors); diff --git a/client/client-core/src/main/java/software/amazon/smithy/java/client/core/ClientContext.java b/client/client-core/src/main/java/software/amazon/smithy/java/client/core/ClientContext.java new file mode 100644 index 000000000..d1db7c1d2 --- /dev/null +++ b/client/client-core/src/main/java/software/amazon/smithy/java/client/core/ClientContext.java @@ -0,0 +1,54 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.client.core; + +import java.time.Duration; +import software.amazon.smithy.java.client.core.endpoint.Endpoint; +import software.amazon.smithy.java.client.core.endpoint.EndpointResolver; +import software.amazon.smithy.java.context.Context; + +/** + * Context parameters that can be provided on a client config and take effect on each request. + * + *

Other per/call settings can be found in {@link CallContext}. + */ +public final class ClientContext { + /** + * A custom endpoint used in each request. + * + *

This can be used in lieu of setting something like {@link EndpointResolver#staticEndpoint}, allowing + * endpoint resolvers like the Smithy Rules Engine resolver to still process and validate endpoints even when a + * custom endpoint is provided. + */ + public static final Context.Key CUSTOM_ENDPOINT = Context.key("Custom endpoint to use with requests"); + + /** + * The name of the application, used in things like user-agent headers. + * + *

This value is used by AWS SDKs, but can be used generically for any client. + * See Application ID for more + * information. + * + *

This value should be less than 50 characters. + */ + public static final Context.Key APPLICATION_ID = Context.key("Application ID"); + + /** + * The total amount of time to wait for an API call to complete, including retries, and serialization. + * + *

This can be overridden per/call too. + */ + public static final Context.Key API_CALL_TIMEOUT = Context.key("API call timeout"); + + /** + * The amount of time to wait for a single, underlying network request to complete before giving up and timing out. + * + *

This can be overridden per/call too. + */ + public static final Context.Key API_CALL_ATTEMPT_TIMEOUT = Context.key("API call attempt timeout"); + + private ClientContext() {} +} diff --git a/client/client-core/src/main/java/software/amazon/smithy/java/client/core/endpoint/Endpoint.java b/client/client-core/src/main/java/software/amazon/smithy/java/client/core/endpoint/Endpoint.java index f5d1c9057..fb11323dd 100644 --- a/client/client-core/src/main/java/software/amazon/smithy/java/client/core/endpoint/Endpoint.java +++ b/client/client-core/src/main/java/software/amazon/smithy/java/client/core/endpoint/Endpoint.java @@ -51,10 +51,13 @@ public interface Endpoint { * * @return the builder. */ + @SuppressWarnings("unchecked") default Builder toBuilder() { var builder = new EndpointImpl.Builder(); builder.uri(uri()); - properties().forEach(k -> builder.properties.put(k, property(k))); + for (var e : properties()) { + builder.putProperty((Context.Key) e, property(e)); + } for (EndpointAuthScheme authScheme : authSchemes()) { builder.addAuthScheme(authScheme); } diff --git a/client/client-core/src/main/java/software/amazon/smithy/java/client/core/endpoint/EndpointContext.java b/client/client-core/src/main/java/software/amazon/smithy/java/client/core/endpoint/EndpointContext.java new file mode 100644 index 000000000..ccd6f784b --- /dev/null +++ b/client/client-core/src/main/java/software/amazon/smithy/java/client/core/endpoint/EndpointContext.java @@ -0,0 +1,23 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.client.core.endpoint; + +import java.util.List; +import java.util.Map; +import software.amazon.smithy.java.context.Context; + +/** + * Context parameters specifically relevant to a resolved {@link Endpoint} property. + */ +public final class EndpointContext { + + private EndpointContext() {} + + /** + * Assigns headers to an endpoint. These are typically HTTP headers. + */ + public static final Context.Key>> HEADERS = Context.key("Endpoint headers"); +} diff --git a/client/client-core/src/main/java/software/amazon/smithy/java/client/core/endpoint/EndpointImpl.java b/client/client-core/src/main/java/software/amazon/smithy/java/client/core/endpoint/EndpointImpl.java index dd09e8ba9..11da6cad2 100644 --- a/client/client-core/src/main/java/software/amazon/smithy/java/client/core/endpoint/EndpointImpl.java +++ b/client/client-core/src/main/java/software/amazon/smithy/java/client/core/endpoint/EndpointImpl.java @@ -23,8 +23,11 @@ final class EndpointImpl implements Endpoint { private EndpointImpl(Builder builder) { this.uri = Objects.requireNonNull(builder.uri); - this.authSchemes = List.copyOf(builder.authSchemes); - this.properties = Map.copyOf(builder.properties); + this.authSchemes = builder.authSchemes == null ? List.of() : builder.authSchemes; + this.properties = builder.properties == null ? Map.of() : builder.properties; + // Clear out the builder, making this class immutable and the builder still reusable. + builder.authSchemes = null; + builder.properties = null; } @Override @@ -66,11 +69,16 @@ public int hashCode() { return Objects.hash(uri, authSchemes, properties); } + @Override + public String toString() { + return "Endpoint{uri=" + uri + ", authSchemes=" + authSchemes + ", properties=" + properties + '}'; + } + static final class Builder implements Endpoint.Builder { private URI uri; - private final List authSchemes = new ArrayList<>(); - final Map, Object> properties = new HashMap<>(); + private List authSchemes; + private Map, Object> properties; @Override public Builder uri(URI uri) { @@ -89,12 +97,18 @@ public Builder uri(String uri) { @Override public Builder addAuthScheme(EndpointAuthScheme authScheme) { + if (this.authSchemes == null) { + this.authSchemes = new ArrayList<>(); + } this.authSchemes.add(authScheme); return this; } @Override public Builder putProperty(Context.Key property, T value) { + if (this.properties == null) { + this.properties = new HashMap<>(); + } properties.put(property, value); return this; } diff --git a/client/client-core/src/main/java/software/amazon/smithy/java/client/core/endpoint/EndpointResolver.java b/client/client-core/src/main/java/software/amazon/smithy/java/client/core/endpoint/EndpointResolver.java index 7d0f8bba5..24842640e 100644 --- a/client/client-core/src/main/java/software/amazon/smithy/java/client/core/endpoint/EndpointResolver.java +++ b/client/client-core/src/main/java/software/amazon/smithy/java/client/core/endpoint/EndpointResolver.java @@ -72,7 +72,7 @@ static EndpointResolver staticEndpoint(URI endpoint) { */ static EndpointResolver staticHost(Endpoint endpoint) { Objects.requireNonNull(endpoint); - return params -> CompletableFuture.completedFuture(endpoint); + return new StaticHostResolver(endpoint); } /** @@ -83,8 +83,7 @@ static EndpointResolver staticHost(Endpoint endpoint) { */ static EndpointResolver staticHost(String endpoint) { Objects.requireNonNull(endpoint); - var ep = Endpoint.builder().uri(endpoint).build(); - return params -> CompletableFuture.completedFuture(ep); + return staticHost(Endpoint.builder().uri(endpoint).build()); } /** @@ -95,7 +94,6 @@ static EndpointResolver staticHost(String endpoint) { */ static EndpointResolver staticHost(URI endpoint) { Objects.requireNonNull(endpoint); - var ep = Endpoint.builder().uri(endpoint).build(); - return params -> CompletableFuture.completedFuture(ep); + return staticHost(Endpoint.builder().uri(endpoint).build()); } } diff --git a/client/client-core/src/main/java/software/amazon/smithy/java/client/core/endpoint/StaticHostResolver.java b/client/client-core/src/main/java/software/amazon/smithy/java/client/core/endpoint/StaticHostResolver.java new file mode 100644 index 000000000..04ea56435 --- /dev/null +++ b/client/client-core/src/main/java/software/amazon/smithy/java/client/core/endpoint/StaticHostResolver.java @@ -0,0 +1,20 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.client.core.endpoint; + +import java.util.concurrent.CompletableFuture; + +/** + * An endpoint resolver that always returns the same endpoint. + * + * @param endpoint Endpoint to return exactly. + */ +record StaticHostResolver(Endpoint endpoint) implements EndpointResolver { + @Override + public CompletableFuture resolveEndpoint(EndpointResolverParams params) { + return CompletableFuture.completedFuture(endpoint); + } +} diff --git a/client/client-core/src/test/java/software/amazon/smithy/java/client/core/ClientTest.java b/client/client-core/src/test/java/software/amazon/smithy/java/client/core/ClientTest.java index 7a00ac0e8..611c47143 100644 --- a/client/client-core/src/test/java/software/amazon/smithy/java/client/core/ClientTest.java +++ b/client/client-core/src/test/java/software/amazon/smithy/java/client/core/ClientTest.java @@ -138,14 +138,14 @@ public void allowsInterceptorRequestOverrides() throws URISyntaxException { @Override public ClientConfig modifyBeforeCall(CallHook hook) { var override = RequestOverrideConfig.builder() - .putConfig(CallContext.APPLICATION_ID, id) + .putConfig(ClientContext.APPLICATION_ID, id) .build(); return hook.config().withRequestOverride(override); } @Override public void readBeforeExecution(InputHook hook) { - assertThat(hook.context().get(CallContext.APPLICATION_ID), equalTo(id)); + assertThat(hook.context().get(ClientContext.APPLICATION_ID), equalTo(id)); } })) .endpointResolver(EndpointResolver.staticEndpoint(new URI("http://localhost"))) @@ -171,15 +171,15 @@ public void requestOverridesPerCallTakePrecedence() throws URISyntaxException { public ClientConfig modifyBeforeCall(CallHook hook) { // Note that the overrides given to the call itself will override interceptors. var override = RequestOverrideConfig.builder() - .putConfig(CallContext.APPLICATION_ID, "foo") + .putConfig(ClientContext.APPLICATION_ID, "foo") .build(); return hook.config().withRequestOverride(override); } @Override public void readBeforeExecution(InputHook hook) { - assertThat(hook.context().get(CallContext.APPLICATION_ID), equalTo(id)); - assertThat(hook.context().get(CallContext.API_CALL_TIMEOUT), equalTo(Duration.ofMinutes(2))); + assertThat(hook.context().get(ClientContext.APPLICATION_ID), equalTo(id)); + assertThat(hook.context().get(ClientContext.API_CALL_TIMEOUT), equalTo(Duration.ofMinutes(2))); } })) .endpointResolver(EndpointResolver.staticEndpoint(new URI("http://localhost"))) @@ -190,8 +190,32 @@ public void readBeforeExecution(InputHook hook) { c.call("GetSprocket", Document.ofObject(new HashMap<>()), RequestOverrideConfig.builder() - .putConfig(CallContext.API_CALL_TIMEOUT, Duration.ofMinutes(2)) - .putConfig(CallContext.APPLICATION_ID, id) // this will be take precedence + .putConfig(ClientContext.API_CALL_TIMEOUT, Duration.ofMinutes(2)) + .putConfig(ClientContext.APPLICATION_ID, id) // this will be take precedence .build()); } + + @Test + public void setsCustomEndpoint() { + var queue = new MockQueue(); + queue.enqueue(HttpResponse.builder().statusCode(200).build()); + + DynamicClient c = DynamicClient.builder() + .model(MODEL) + .service(SERVICE) + .protocol(new RestJsonClientProtocol(SERVICE)) + .addPlugin(MockPlugin.builder().addQueue(queue).build()) + .addPlugin(config -> config.addInterceptor(new ClientInterceptor() { + @Override + public void readBeforeExecution(InputHook hook) { + assertThat(hook.context().get(ClientContext.CUSTOM_ENDPOINT).uri().toString(), + equalTo("https://example.com")); + } + })) + .endpoint("https://example.com") + .authSchemeResolver(AuthSchemeResolver.NO_AUTH) + .build(); + + c.call("GetSprocket"); + } } diff --git a/client/client-http/src/main/java/software/amazon/smithy/java/client/http/plugins/UserAgentPlugin.java b/client/client-http/src/main/java/software/amazon/smithy/java/client/http/plugins/UserAgentPlugin.java index 39c0f13b4..14e53cd2f 100644 --- a/client/client-http/src/main/java/software/amazon/smithy/java/client/http/plugins/UserAgentPlugin.java +++ b/client/client-http/src/main/java/software/amazon/smithy/java/client/http/plugins/UserAgentPlugin.java @@ -9,6 +9,7 @@ import java.util.Locale; import software.amazon.smithy.java.client.core.CallContext; import software.amazon.smithy.java.client.core.ClientConfig; +import software.amazon.smithy.java.client.core.ClientContext; import software.amazon.smithy.java.client.core.ClientPlugin; import software.amazon.smithy.java.client.core.ClientTransport; import software.amazon.smithy.java.client.core.interceptors.ClientInterceptor; @@ -109,7 +110,7 @@ private static String createUa(Context context) { } private static String resolveAppId(Context context) { - var appId = context.get(CallContext.APPLICATION_ID); + var appId = context.get(ClientContext.APPLICATION_ID); if (appId == null) { appId = System.getenv(SYSTEM_APP_ID); } diff --git a/client/client-http/src/test/java/software/amazon/smithy/java/client/http/plugins/UserAgentPluginTest.java b/client/client-http/src/test/java/software/amazon/smithy/java/client/http/plugins/UserAgentPluginTest.java index eb5b6d184..1c3986177 100644 --- a/client/client-http/src/test/java/software/amazon/smithy/java/client/http/plugins/UserAgentPluginTest.java +++ b/client/client-http/src/test/java/software/amazon/smithy/java/client/http/plugins/UserAgentPluginTest.java @@ -16,6 +16,7 @@ import java.util.Set; import org.junit.jupiter.api.Test; import software.amazon.smithy.java.client.core.CallContext; +import software.amazon.smithy.java.client.core.ClientContext; import software.amazon.smithy.java.client.core.FeatureId; import software.amazon.smithy.java.client.core.interceptors.RequestHook; import software.amazon.smithy.java.context.Context; @@ -49,7 +50,7 @@ public void addsDefaultAgent() throws Exception { public void addsApplicationId() throws Exception { UserAgentPlugin.UserAgentInterceptor interceptor = new UserAgentPlugin.UserAgentInterceptor(); var context = Context.create(); - context.put(CallContext.APPLICATION_ID, "hello there"); + context.put(ClientContext.APPLICATION_ID, "hello there"); var req = HttpRequest.builder().uri(new URI("/")).method("GET").build(); var foo = new Foo(); var updated = interceptor.modifyBeforeSigning(new RequestHook<>(createOperation(), context, foo, req)); diff --git a/client/client-rulesengine/build.gradle.kts b/client/client-rulesengine/build.gradle.kts new file mode 100644 index 000000000..d27ff49bd --- /dev/null +++ b/client/client-rulesengine/build.gradle.kts @@ -0,0 +1,28 @@ +plugins { + id("smithy-java.module-conventions") + id("me.champeau.jmh") version "0.7.3" +} + +description = "Implements the rules engine traits used to resolve endpoints" + +extra["displayName"] = "Smithy :: Java :: Client :: Endpoint Rules" +extra["moduleName"] = "software.amazon.smithy.java.client.endpointrules" + +dependencies { + api(project(":client:client-core")) + api(project(":jmespath")) + api(libs.smithy.rules) + implementation(project(":logging")) + + testImplementation(project(":aws:client:aws-client-awsjson")) + testImplementation(project(":client:dynamic-client")) +} + +jmh { + warmupIterations = 2 + iterations = 5 + fork = 1 + // profilers.add("async:output=flamegraph") + // profilers.add("gc") + duplicateClassesStrategy = DuplicatesStrategy.WARN +} diff --git a/client/client-rulesengine/src/jmh/java/software/amazon/smithy/java/client/rulesengine/VmBench.java b/client/client-rulesengine/src/jmh/java/software/amazon/smithy/java/client/rulesengine/VmBench.java new file mode 100644 index 000000000..f3b7cbbbf --- /dev/null +++ b/client/client-rulesengine/src/jmh/java/software/amazon/smithy/java/client/rulesengine/VmBench.java @@ -0,0 +1,96 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.client.rulesengine; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; +import software.amazon.smithy.java.context.Context; +import software.amazon.smithy.model.node.Node; +import software.amazon.smithy.rulesengine.language.EndpointRuleSet; +import software.amazon.smithy.utils.IoUtils; + +@State(Scope.Benchmark) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +@BenchmarkMode(Mode.AverageTime) +@Warmup( + iterations = 2, + time = 3, + timeUnit = TimeUnit.SECONDS) +@Measurement( + iterations = 3, + time = 3, + timeUnit = TimeUnit.SECONDS) +@Fork(1) +public class VmBench { + + private static final Map> CASES = Map.ofEntries( + Map.entry("example-complex-ruleset.json-1", + Map.of( + "Endpoint", + "https://example.com", + "UseFIPS", + false)), + Map.entry("minimal-ruleset.json-1", Map.of("Region", "us-east-1"))); + + @Param({ + "yes", + "no", + }) + private String optimize; + + @Param({ + "example-complex-ruleset.json-1", + "minimal-ruleset.json-1" + }) + private String testName; + + private EndpointRuleSet ruleSet; + private Map parameters; + private RulesProgram program; + private Context ctx; + + @Setup + public void setup() throws Exception { + parameters = new HashMap<>(CASES.get(testName)); + var actualFile = testName.substring(0, testName.length() - 2); + var url = VmBench.class.getResource(actualFile); + if (url == null) { + throw new RuntimeException("Test case not found: " + actualFile); + } + var data = Node.parse(IoUtils.readUtf8Url(url)); + ruleSet = EndpointRuleSet.fromNode(data); + + var engine = new RulesEngine(); + if (optimize.equals("no")) { + engine.disableOptimizations(); + } + program = engine.compile(ruleSet); + ctx = Context.create(); + } + + // @Benchmark + // public Object compile() { + // // TODO + // return null; + // } + + @Benchmark + public Object evaluate() { + return program.resolveEndpoint(ctx, parameters); + } +} diff --git a/client/client-rulesengine/src/main/java/software/amazon/smithy/java/client/rulesengine/AttrExpression.java b/client/client-rulesengine/src/main/java/software/amazon/smithy/java/client/rulesengine/AttrExpression.java new file mode 100644 index 000000000..b3fe6c9c4 --- /dev/null +++ b/client/client-rulesengine/src/main/java/software/amazon/smithy/java/client/rulesengine/AttrExpression.java @@ -0,0 +1,136 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.client.rulesengine; + +import java.net.URI; +import java.util.List; +import java.util.Map; +import software.amazon.smithy.rulesengine.language.syntax.expressions.functions.GetAttr; + +/** + * Implements the getAttr function by extracting paths from objects. + */ +sealed interface AttrExpression { + + Object apply(Object o); + + static AttrExpression from(GetAttr getAttr) { + var path = getAttr.getPath(); + + // Set the toString value on the final result. + String str = getAttr.toString(); // in the form of something#path + int position = str.lastIndexOf('#'); + var tostringValue = str.substring(position + 1); + + if (path.isEmpty()) { + throw new UnsupportedOperationException("Invalid getAttr expression: requires at least one part"); + } else if (path.size() == 1) { + return new ToString(tostringValue, from(getAttr.getPath().get(0))); + } + + // Parse the multi-level expression ("foo.bar.baz[9]"). + var result = new AndThen(from(path.get(0)), from(path.get(1))); + for (var i = 2; i < path.size(); i++) { + result = new AndThen(result, from(path.get(i))); + } + + return new ToString(tostringValue, result); + } + + private static AttrExpression from(GetAttr.Part part) { + if (part instanceof GetAttr.Part.Key k) { + return new GetKey(k.key().toString()); + } else if (part instanceof GetAttr.Part.Index i) { + return new GetIndex(i.index()); + } else { + throw new UnsupportedOperationException("Unexpected GetAttr part: " + part); + } + } + + /** + * Creates an AttrExpression from a string. Generally used when loading from pre-compiled programs. + * + * @param value Value to parse. + * @return the expression. + */ + static AttrExpression parse(String value) { + var values = value.split("\\."); + + // Parse a single-level expression ("foo" or "bar[0]"). + if (values.length == 1) { + return new ToString(value, parsePart(value)); + } + + // Parse the multi-level expression ("foo.bar.baz[9]"). + var result = new AndThen(parsePart(values[0]), parsePart(values[1])); + for (var i = 2; i < values.length; i++) { + result = new AndThen(result, parsePart(values[i])); + } + + // Set the toString value on the final result. + return new ToString(value, result); + } + + private static AttrExpression parsePart(String part) { + int position = part.indexOf('['); + if (position == -1) { + return new GetKey(part); + } else { + String numberString = part.substring(position + 1, part.length() - 1); + int index = Integer.parseInt(numberString); + String key = part.substring(0, position); + return new AndThen(new GetKey(key), new GetIndex(index)); + } + } + + record ToString(String original, AttrExpression delegate) implements AttrExpression { + @Override + public Object apply(Object o) { + return delegate.apply(o); + } + + @Override + public String toString() { + return original; + } + } + + record AndThen(AttrExpression left, AttrExpression right) implements AttrExpression { + @Override + public Object apply(Object o) { + var result = left.apply(o); + if (result != null) { + result = right.apply(result); + } + return result; + } + } + + record GetKey(String key) implements AttrExpression { + @Override + @SuppressWarnings("rawtypes") + public Object apply(Object o) { + if (o instanceof Map m) { + return m.get(key); + } else if (o instanceof URI u) { + return EndpointUtils.getUriProperty(u, key); + } else { + return null; + } + } + } + + record GetIndex(int index) implements AttrExpression { + @Override + @SuppressWarnings("rawtypes") + public Object apply(Object o) { + if (o instanceof List l && l.size() > index) { + return l.get(index); + } + return null; + } + } +} diff --git a/client/client-rulesengine/src/main/java/software/amazon/smithy/java/client/rulesengine/ContextProvider.java b/client/client-rulesengine/src/main/java/software/amazon/smithy/java/client/rulesengine/ContextProvider.java new file mode 100644 index 000000000..ccd484aae --- /dev/null +++ b/client/client-rulesengine/src/main/java/software/amazon/smithy/java/client/rulesengine/ContextProvider.java @@ -0,0 +1,172 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.client.rulesengine; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import software.amazon.smithy.java.core.schema.ApiOperation; +import software.amazon.smithy.java.core.schema.Schema; +import software.amazon.smithy.java.core.schema.SerializableStruct; +import software.amazon.smithy.java.core.serde.document.Document; +import software.amazon.smithy.java.jmespath.JMESPathDocumentQuery; +import software.amazon.smithy.jmespath.JmespathExpression; +import software.amazon.smithy.model.shapes.ShapeId; + +/** + * Provides context parameters from operations using {@code smithy.rules#contextParam}, + * {@code smithy.rules#operationContextParams}, and {@code smithy.rules#staticContextParams} traits. + * + *

The results of finding operation context parameters from an operation are cached and reused over the life of + * a client per/operation. + */ +sealed interface ContextProvider { + + void addContext(ApiOperation operation, SerializableStruct input, Map params); + + final class OrchestratingProvider implements ContextProvider { + private final ConcurrentMap PROVIDERS = new ConcurrentHashMap<>(); + + @Override + public void addContext(ApiOperation operation, SerializableStruct input, Map params) { + var provider = PROVIDERS.get(operation.schema().id()); + if (provider == null) { + provider = createProvider(operation); + var fresh = PROVIDERS.putIfAbsent(operation.schema().id(), provider); + if (fresh != null) { + provider = fresh; + } + } + provider.addContext(operation, input, params); + } + + private ContextProvider createProvider(ApiOperation operation) { + List providers = new ArrayList<>(); + var operationSchema = operation.schema(); + var inputSchema = operation.inputSchema(); + ContextParamProvider.compute(providers, inputSchema); + ContextPathProvider.compute(providers, operationSchema); + StaticParamsProvider.compute(providers, operationSchema); // overrides everything else + return MultiContextParamProvider.from(providers); + } + } + + // Find the smithy.rules#staticContextParams on the operation. + final class StaticParamsProvider implements ContextProvider { + private final Map params; + + StaticParamsProvider(Map params) { + this.params = params; + } + + @Override + public void addContext(ApiOperation operation, SerializableStruct input, Map params) { + params.putAll(this.params); + } + + static void compute(List providers, Schema operation) { + var staticParamsTrait = operation.getTrait(EndpointRulesPlugin.STATIC_CONTEXT_PARAMS_TRAIT); + if (staticParamsTrait == null) { + return; + } + + Map result = new HashMap<>(staticParamsTrait.getParameters().size()); + for (var entry : staticParamsTrait.getParameters().entrySet()) { + result.put(entry.getKey(), EndpointUtils.convertNode(entry.getValue().getValue())); + } + + providers.add(new StaticParamsProvider(result)); + } + } + + // Find smithy.rules#contextParam trait on operation input members. + final class ContextParamProvider implements ContextProvider { + private final Schema member; + private final String name; + + ContextParamProvider(Schema member, String name) { + this.member = member; + this.name = name; + } + + @Override + public void addContext(ApiOperation operation, SerializableStruct input, Map params) { + var value = input.getMemberValue(member); + if (value != null) { + params.put(name, value); + } + } + + static void compute(List providers, Schema inputSchema) { + for (var member : inputSchema.members()) { + var ctxTrait = member.getTrait(EndpointRulesPlugin.CONTEXT_PARAM_TRAIT); + if (ctxTrait != null) { + providers.add(new ContextParamProvider(member, ctxTrait.getName())); + } + } + } + } + + // Find the smithy.rules#operationContextParams trait on the operation and each JMESPath to extract. + // TODO: I wish we didn't have to convert input to a document and could use the struct directly. + // We'd need to add a new code path to the jmespath module, something like JMESPathStructQuery. + final class ContextPathProvider implements ContextProvider { + + private final String name; + private final JmespathExpression jp; + + ContextPathProvider(String name, JmespathExpression jp) { + this.name = name; + this.jp = jp; + } + + @Override + public void addContext(ApiOperation operation, SerializableStruct input, Map params) { + var doc = Document.of(input); + var result = JMESPathDocumentQuery.query(jp, doc); + if (result != null) { + params.put(name, result.asObject()); + } + } + + static void compute(List providers, Schema operation) { + var params = operation.getTrait(EndpointRulesPlugin.OPERATION_CONTEXT_PARAMS_TRAIT); + if (params == null) { + return; + } + + for (var param : params.getParameters().entrySet()) { + var name = param.getKey(); + var path = param.getValue().getPath(); + var jp = JmespathExpression.parse(path); + providers.add(new ContextPathProvider(name, jp)); + } + } + } + + // Applies multiple context providers. + final class MultiContextParamProvider implements ContextProvider { + private final List providers; + + MultiContextParamProvider(List providers) { + this.providers = providers; + } + + static ContextProvider from(List providers) { + return providers.size() == 1 ? providers.get(0) : new MultiContextParamProvider(providers); + } + + @Override + public void addContext(ApiOperation operation, SerializableStruct input, Map params) { + for (ContextProvider provider : providers) { + provider.addContext(operation, input, params); + } + } + } +} diff --git a/client/client-rulesengine/src/main/java/software/amazon/smithy/java/client/rulesengine/CseOptimizer.java b/client/client-rulesengine/src/main/java/software/amazon/smithy/java/client/rulesengine/CseOptimizer.java new file mode 100644 index 000000000..ef61ee8aa --- /dev/null +++ b/client/client-rulesengine/src/main/java/software/amazon/smithy/java/client/rulesengine/CseOptimizer.java @@ -0,0 +1,69 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.client.rulesengine; + +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import software.amazon.smithy.rulesengine.language.syntax.expressions.Expression; +import software.amazon.smithy.rulesengine.language.syntax.rule.Rule; +import software.amazon.smithy.rulesengine.language.syntax.rule.TreeRule; + +/** + * Eliminates common subexpressions so they're stored in a single registry. + * + *

This currently only eliminates top-level condition functions that are duplicates across any rule or tree-rule + * and at any depth. It doesn't eliminate nested expressions of functions. + */ +final class CseOptimizer { + + // The score required to make a condition an eliminated CSE. + private static final int MINIMUM_SCORE = 5; + + // Counts how many times an expression is duplicated. + private final Map conditions = new HashMap<>(); + + static Map apply(List rules) { + var cse = new CseOptimizer(); + for (var rule : rules) { + cse.apply(1, rule); + } + return cse.getCse(); + } + + private void apply(int depth, Rule rule) { + if (rule instanceof TreeRule t) { + for (var c : t.getConditions()) { + findCse(depth, c.getFunction()); + } + for (var r : t.getRules()) { + apply(depth + 1, r); + } + } else { + for (var c : rule.getConditions()) { + findCse(depth, c.getFunction()); + } + } + } + + private void findCse(int depth, Expression f) { + // Add to the score for each expression, discounting occurrences the deeper they appear. + conditions.put(f, conditions.getOrDefault(f, 0.0) + (1 / (depth * 0.5))); + } + + private Map getCse() { + // Only keep duplicated expressions that have a pretty high score. + Map result = new LinkedHashMap<>(); + for (var e : conditions.entrySet()) { + if (e.getValue() > MINIMUM_SCORE) { + result.put(e.getKey(), (byte) 0); + } + } + + return result; + } +} diff --git a/client/client-rulesengine/src/main/java/software/amazon/smithy/java/client/rulesengine/EndpointRulesPlugin.java b/client/client-rulesengine/src/main/java/software/amazon/smithy/java/client/rulesengine/EndpointRulesPlugin.java new file mode 100644 index 000000000..6e2ec19dc --- /dev/null +++ b/client/client-rulesengine/src/main/java/software/amazon/smithy/java/client/rulesengine/EndpointRulesPlugin.java @@ -0,0 +1,90 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.client.rulesengine; + +import software.amazon.smithy.java.client.core.ClientConfig; +import software.amazon.smithy.java.client.core.ClientContext; +import software.amazon.smithy.java.client.core.ClientPlugin; +import software.amazon.smithy.java.core.schema.TraitKey; +import software.amazon.smithy.rulesengine.traits.ContextParamTrait; +import software.amazon.smithy.rulesengine.traits.EndpointRuleSetTrait; +import software.amazon.smithy.rulesengine.traits.OperationContextParamsTrait; +import software.amazon.smithy.rulesengine.traits.StaticContextParamsTrait; + +/** + * Attempts to resolve endpoints using smithy.rules#endpointRuleSet or a {@link RulesProgram} compiled from this trait. + */ +public final class EndpointRulesPlugin implements ClientPlugin { + + public static final TraitKey STATIC_CONTEXT_PARAMS_TRAIT = + TraitKey.get(StaticContextParamsTrait.class); + + public static final TraitKey OPERATION_CONTEXT_PARAMS_TRAIT = + TraitKey.get(OperationContextParamsTrait.class); + + public static final TraitKey CONTEXT_PARAM_TRAIT = TraitKey.get(ContextParamTrait.class); + + public static final TraitKey ENDPOINT_RULESET_TRAIT = + TraitKey.get(EndpointRuleSetTrait.class); + + private final RulesProgram program; + + private EndpointRulesPlugin(RulesProgram program) { + this.program = program; + } + + /** + * Create a RulesEnginePlugin from a precompiled {@link RulesProgram}. + * + *

This is typically used by code-generated clients. + * + * @param program Program used to resolve endpoint. + * @return the rules engine plugin. + */ + public static EndpointRulesPlugin from(RulesProgram program) { + return new EndpointRulesPlugin(program); + } + + /** + * Creates an EndpointRulesPlugin that waits to create a program until configuring the client. It looks for the + * relevant Smithy traits, and if found, compiles them and sets up a resolver. If the traits can't be found, the + * resolver is not updated. If a resolver is already set, it is not changed. + * + * @return the plugin. + */ + public static EndpointRulesPlugin create() { + return new EndpointRulesPlugin(null); + } + + /** + * Gets the endpoint rules program that was compiled, or null if no rules were found on the service. + * + * @return the rules program or null. + */ + public RulesProgram getProgram() { + return program; + } + + @Override + public void configureClient(ClientConfig.Builder config) { + // Only modify the endpoint resolver if it isn't set already or if CUSTOM_ENDPOINT is set, + // and if a program was provided. + if (config.endpointResolver() == null || config.context().get(ClientContext.CUSTOM_ENDPOINT) != null) { + if (program != null) { + applyResolver(program, config); + } else if (config.service() != null) { + var ruleset = config.service().schema().getTrait(ENDPOINT_RULESET_TRAIT); + if (ruleset != null) { + applyResolver(new RulesEngine().compile(ruleset.getEndpointRuleSet()), config); + } + } + } + } + + private void applyResolver(RulesProgram applyProgram, ClientConfig.Builder config) { + config.endpointResolver(new EndpointRulesResolver(applyProgram)); + } +} diff --git a/client/client-rulesengine/src/main/java/software/amazon/smithy/java/client/rulesengine/EndpointRulesResolver.java b/client/client-rulesengine/src/main/java/software/amazon/smithy/java/client/rulesengine/EndpointRulesResolver.java new file mode 100644 index 000000000..a66924017 --- /dev/null +++ b/client/client-rulesengine/src/main/java/software/amazon/smithy/java/client/rulesengine/EndpointRulesResolver.java @@ -0,0 +1,44 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.client.rulesengine; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import software.amazon.smithy.java.client.core.endpoint.Endpoint; +import software.amazon.smithy.java.client.core.endpoint.EndpointResolver; +import software.amazon.smithy.java.client.core.endpoint.EndpointResolverParams; +import software.amazon.smithy.java.core.schema.ApiOperation; +import software.amazon.smithy.java.core.schema.SerializableStruct; + +/** + * Endpoint resolver that uses the endpoint rules engine. + */ +final class EndpointRulesResolver implements EndpointResolver { + + private final RulesProgram program; + private final ContextProvider operationContextParams = new ContextProvider.OrchestratingProvider(); + + EndpointRulesResolver(RulesProgram program) { + this.program = program; + } + + @Override + public CompletableFuture resolveEndpoint(EndpointResolverParams params) { + try { + var endpointParams = createEndpointParams(params.operation(), params.inputValue()); + return CompletableFuture.completedFuture(program.resolveEndpoint(params.context(), endpointParams)); + } catch (RulesEvaluationError e) { + return CompletableFuture.failedFuture(e); + } + } + + private Map createEndpointParams(ApiOperation operation, SerializableStruct input) { + Map params = new HashMap<>(); + operationContextParams.addContext(operation, input, params); + return params; + } +} diff --git a/client/client-rulesengine/src/main/java/software/amazon/smithy/java/client/rulesengine/EndpointUtils.java b/client/client-rulesengine/src/main/java/software/amazon/smithy/java/client/rulesengine/EndpointUtils.java new file mode 100644 index 000000000..4e5e35606 --- /dev/null +++ b/client/client-rulesengine/src/main/java/software/amazon/smithy/java/client/rulesengine/EndpointUtils.java @@ -0,0 +1,158 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.client.rulesengine; + +import java.net.URI; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import software.amazon.smithy.model.node.ArrayNode; +import software.amazon.smithy.model.node.BooleanNode; +import software.amazon.smithy.model.node.Node; +import software.amazon.smithy.model.node.NumberNode; +import software.amazon.smithy.model.node.ObjectNode; +import software.amazon.smithy.model.node.StringNode; +import software.amazon.smithy.rulesengine.language.evaluation.value.ArrayValue; +import software.amazon.smithy.rulesengine.language.evaluation.value.BooleanValue; +import software.amazon.smithy.rulesengine.language.evaluation.value.EmptyValue; +import software.amazon.smithy.rulesengine.language.evaluation.value.IntegerValue; +import software.amazon.smithy.rulesengine.language.evaluation.value.RecordValue; +import software.amazon.smithy.rulesengine.language.evaluation.value.StringValue; +import software.amazon.smithy.rulesengine.language.evaluation.value.Value; +import software.amazon.smithy.rulesengine.language.syntax.expressions.functions.ParseUrl; + +final class EndpointUtils { + + private EndpointUtils() {} + + // "The type of the value MUST be either a string, boolean or an array of string." + static Object convertNode(Node value, boolean allowAllTypes) { + if (value instanceof StringNode s) { + return s.getValue(); + } else if (value instanceof BooleanNode b) { + return b.getValue(); + } else if (value instanceof ArrayNode a) { + List result = new ArrayList<>(a.size()); + for (var e : a.getElements()) { + result.add(convertNode(e, allowAllTypes)); + } + return result; + } else if (allowAllTypes) { + if (value instanceof NumberNode n) { + return n.getValue(); + } else if (value instanceof ObjectNode o) { + var result = new HashMap(o.size()); + for (var e : o.getStringMap().entrySet()) { + result.put(e.getKey(), convertNode(e.getValue(), allowAllTypes)); + } + return result; + } else if (value.isNullNode()) { + return null; + } else { + throw new RulesEvaluationError("Unsupported endpoint ruleset parameter type: " + value); + } + } + + throw new RulesEvaluationError("Unsupported endpoint ruleset parameter: " + value); + } + + static Object convertNode(Node value) { + return convertNode(value, false); + } + + static Object convertInputParamValue(Value value) { + if (value instanceof StringValue s) { + return s.getValue(); + } else if (value instanceof IntegerValue i) { + return i.getValue(); + } else if (value instanceof ArrayValue a) { + var result = new ArrayList<>(); + for (var v : a.getValues()) { + result.add(convertInputParamValue(v)); + } + return result; + } else if (value instanceof EmptyValue) { + return null; + } else if (value instanceof BooleanValue b) { + return b.getValue(); + } else if (value instanceof RecordValue r) { + var result = new HashMap<>(); + for (var e : r.getValue().entrySet()) { + result.put(e.getKey().getName().getValue(), convertInputParamValue(e.getValue())); + } + return result; + } else { + throw new RulesEvaluationError("Unsupported value type: " + value); + } + } + + static Object verifyObject(Object value) { + if (value instanceof String + || value instanceof Number + || value instanceof Boolean + || value instanceof StringTemplate + || value instanceof URI) { + return value; + } + + if (value instanceof List l) { + for (var v : l) { + verifyObject(v); + } + return value; + } + + if (value instanceof Map m) { + for (var e : m.entrySet()) { + if (!(e.getKey() instanceof String)) { + throw new UnsupportedOperationException("Endpoint parameter maps must use string keys. Found " + e); + } + verifyObject(e.getKey()); + verifyObject(e.getValue()); + } + return m; + } + + throw new UnsupportedOperationException("Unsupported endpoint rules value given: " + value); + } + + // Read little-endian unsigned short (2 bytes) + static int bytesToShort(byte[] instructions, int offset) { + int low = instructions[offset] & 0xFF; + int high = instructions[offset + 1] & 0xFF; + return (high << 8) | low; + } + + // Write little-endian unsigned short (2 bytes) + static void shortToTwoBytes(int value, byte[] instructions, int offset) { + instructions[offset] = (byte) (value & 0xFF); + instructions[offset + 1] = (byte) ((value >> 8) & 0xFF); + } + + static Object getUriProperty(URI uri, String key) { + return switch (key) { + case "scheme" -> uri.getScheme(); + case "path" -> uri.getRawPath(); + case "normalizedPath" -> ParseUrl.normalizePath(uri.getRawPath()); + case "authority" -> uri.getAuthority(); + case "isIp" -> ParseUrl.isIpAddr(uri.getHost()); + default -> null; + }; + } + + static T castFnArgument(Object value, Class type, String method, int position) { + try { + return type.cast(value); + } catch (ClassCastException e) { + throw new RulesEvaluationError(String.format("Expected %s argument %d to be %s, but given %s", + method, + position, + type.getName(), + value.getClass().getName())); + } + } +} diff --git a/client/client-rulesengine/src/main/java/software/amazon/smithy/java/client/rulesengine/ParamDefinition.java b/client/client-rulesengine/src/main/java/software/amazon/smithy/java/client/rulesengine/ParamDefinition.java new file mode 100644 index 000000000..6e66f1a30 --- /dev/null +++ b/client/client-rulesengine/src/main/java/software/amazon/smithy/java/client/rulesengine/ParamDefinition.java @@ -0,0 +1,20 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.client.rulesengine; + +/** + * Defines a parameter used in {@link RulesProgram}. + * + * @param name Name of the parameter. + * @param required True if the parameter is required. + * @param defaultValue An object value that contains a default value for input parameters. + * @param builtin A string that defines the builtin that provides a default value for input parameters. + */ +public record ParamDefinition(String name, boolean required, Object defaultValue, String builtin) { + public ParamDefinition(String name) { + this(name, false, null, null); + } +} diff --git a/client/client-rulesengine/src/main/java/software/amazon/smithy/java/client/rulesengine/RulesCompiler.java b/client/client-rulesengine/src/main/java/software/amazon/smithy/java/client/rulesengine/RulesCompiler.java new file mode 100644 index 000000000..27b71c6d6 --- /dev/null +++ b/client/client-rulesengine/src/main/java/software/amazon/smithy/java/client/rulesengine/RulesCompiler.java @@ -0,0 +1,567 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.client.rulesengine; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.BiFunction; +import software.amazon.smithy.java.context.Context; +import software.amazon.smithy.rulesengine.language.EndpointRuleSet; +import software.amazon.smithy.rulesengine.language.syntax.expressions.Expression; +import software.amazon.smithy.rulesengine.language.syntax.expressions.ExpressionVisitor; +import software.amazon.smithy.rulesengine.language.syntax.expressions.Reference; +import software.amazon.smithy.rulesengine.language.syntax.expressions.functions.FunctionDefinition; +import software.amazon.smithy.rulesengine.language.syntax.expressions.functions.GetAttr; +import software.amazon.smithy.rulesengine.language.syntax.expressions.literal.BooleanLiteral; +import software.amazon.smithy.rulesengine.language.syntax.expressions.literal.IntegerLiteral; +import software.amazon.smithy.rulesengine.language.syntax.expressions.literal.Literal; +import software.amazon.smithy.rulesengine.language.syntax.expressions.literal.RecordLiteral; +import software.amazon.smithy.rulesengine.language.syntax.expressions.literal.StringLiteral; +import software.amazon.smithy.rulesengine.language.syntax.expressions.literal.TupleLiteral; +import software.amazon.smithy.rulesengine.language.syntax.rule.Condition; +import software.amazon.smithy.rulesengine.language.syntax.rule.EndpointRule; +import software.amazon.smithy.rulesengine.language.syntax.rule.ErrorRule; +import software.amazon.smithy.rulesengine.language.syntax.rule.Rule; +import software.amazon.smithy.rulesengine.language.syntax.rule.TreeRule; + +final class RulesCompiler { + + private final List extensions; + private final EndpointRuleSet rules; + + private final Map constantPool = new LinkedHashMap<>(); + private final BiFunction builtinProvider; + private final Map cse; + private boolean performOptimizations; + + // The parsed opcodes and operands. + private byte[] instructions = new byte[64]; + private int instructionSize; + + // Parameters and captured variables. + private final List registry = new ArrayList<>(); + + // A map of variable name to stack index. + private final Map> registryIndex = new HashMap<>(); + + // An array of actually used functions. + private final List usedFunctions = new ArrayList<>(); + + // Index of function name to the index in usedFunctions. + private final Map usedFunctionIndex = new HashMap<>(); + + // The resolved VM functions (stdLib + given functions). + private final Map functions; + + // Stack of available and reusable registers. + private final ArrayList> scopedRegisterStack = new ArrayList<>(); + private final Deque availableRegisters = new ArrayDeque<>(); + private int temporaryRegisters = 0; + + RulesCompiler( + List extensions, + EndpointRuleSet rules, + Map functions, + BiFunction builtinProvider, + boolean performOptimizations + ) { + this.extensions = extensions; + this.rules = rules; + this.builtinProvider = builtinProvider; + this.performOptimizations = performOptimizations; + this.functions = functions; + + // Byte 1 is the version byte. + instructions[0] = RulesProgram.VERSION; + // Byte 2 the number of parameters. Byte 3 is the number of temporary registers. Both filled in at the end. + instructionSize = 3; // start from here + + // Add parameters as registry values. + for (var param : rules.getParameters()) { + var defaultValue = param.getDefault().map(EndpointUtils::convertInputParamValue).orElse(null); + var builtinValue = param.getBuiltIn().orElse(null); + addRegister(param.getName().toString(), param.isRequired(), defaultValue, builtinValue); + } + + cse = performOptimizations ? CseOptimizer.apply(rules.getRules()) : Map.of(); + } + + private byte addRegister(String name, boolean required, Object defaultValue, String builtin) { + var register = new ParamDefinition(name, required, defaultValue, builtin); + if (registryIndex.containsKey(name)) { + throw new RulesEvaluationError("Duplicate variable name found in rules: " + name); + } + Deque stack = new ArrayDeque<>(); + stack.push((byte) registry.size()); + registryIndex.put(name, stack); + registry.add(register); + + // Register scopes are tracking by flipping bits of a long. That means a max of 64 registers. + // No real rules definition would have more than 64 registers. + if (registry.size() > 64) { + throw new RulesEvaluationError("Too many registers added to rules engine"); + } + + return (byte) (registry.size() - 1); + } + + private int getConstant(Object value) { + Integer index = constantPool.get(value); + if (index == null) { + index = constantPool.size(); + constantPool.put(value, index); + } + return index; + } + + // Gets a register that _has_ to exist by name. + private byte getRegister(String name) { + return registryIndex.get(name).peek(); + } + + // Used to assign registers in a stack-like manner. Not used to initialize registers. + private byte assignRegister(String name) { + var indices = registryIndex.get(name); + var index = getTempRegister(); + if (indices == null) { + indices = new ArrayDeque<>(); + registryIndex.put(name, indices); + } + indices.add(index); + return index; + } + + // Gets the next available temporary register or creates one. + private byte getTempRegister() { + return !availableRegisters.isEmpty() + ? availableRegisters.pop() + : addRegister("r" + temporaryRegisters++, false, null, null); + } + + private byte getFunctionIndex(String name) { + Byte index = usedFunctionIndex.get(name); + if (index == null) { + var fn = functions.get(name); + if (fn == null) { + throw new RulesEvaluationError("Rules engine referenced unknown function: " + name); + } + index = (byte) usedFunctionIndex.size(); + usedFunctionIndex.put(name, index); + usedFunctions.add(fn); + } + return index; + } + + RulesProgram compile() { + // Compile common subexpression values up front. + if (performOptimizations) { + performOptimizations = false; + for (var e : cse.entrySet()) { + alwaysCompileExpression(e.getKey()); + var register = getTempRegister(); + e.setValue(register); + add_SET_REGISTER(register); + } + performOptimizations = true; + } + + for (var rule : rules.getRules()) { + compileRule(rule); + } + + return buildProgram(); + } + + private void compileRule(Rule rule) { + enterScope(); + if (rule instanceof TreeRule t) { + compileTreeRule(t); + } else if (rule instanceof EndpointRule e) { + compileEndpointRule(e); + } else if (rule instanceof ErrorRule e) { + compileErrorRule(e); + } + exitScope(); + } + + private void enterScope() { + scopedRegisterStack.add(new HashMap<>()); + } + + private void exitScope() { + var value = scopedRegisterStack.remove(scopedRegisterStack.size() - 1); + // Free up assigned temp registers. + for (var entry : value.entrySet()) { + var indices = registryIndex.get(entry.getValue()); + indices.pop(); + availableRegisters.add(entry.getKey()); + } + } + + private void compileTreeRule(TreeRule tree) { + var jump = compileConditions(tree); + // Compile nested rules. + for (var rule : tree.getRules()) { + compileRule(rule); + } + // Patch in the actual jump target for each condition so it skips over the rules. + jump.patchTarget(instructions, instructionSize); + } + + private JumpIfFalsey compileConditions(Rule rule) { + var jump = new JumpIfFalsey(); + for (var condition : rule.getConditions()) { + compileCondition(condition, jump); + } + return jump; + } + + private void compileCondition(Condition condition, JumpIfFalsey jump) { + compileExpression(condition.getFunction()); + // Add an instruction to store the result as a register if the condition requests it. + condition.getResult().ifPresent(result -> { + var varName = result.toString(); + var register = assignRegister(varName); + var position = scopedRegisterStack.size() - 1; + scopedRegisterStack.get(position).put(register, varName); + add_SET_REGISTER(register); + }); + // Add the jump instruction after each condition to skip over more conditions or skip over the rule. + add_JUMP_IF_FALSEY(0); + jump.addPatch(instructionSize - 2); + } + + private void addLiteralOpcodes(Literal literal) { + if (literal instanceof StringLiteral s) { + var st = StringTemplate.from(s.value()); + if (st.expressionCount() == 0) { + add_LOAD_CONST(st.resolve(0, null)); + } else if (st.singularExpression() != null) { + // No need to resolve a template if it's just plucking a single value. + compileExpression(st.singularExpression()); + } else { + // String templates need to push their template placeholders in reverse order. + st.forEachExpression(this::compileExpression); + add_RESOLVE_TEMPLATE(st); + } + } else if (literal instanceof TupleLiteral t) { + for (var e : t.members()) { + addLiteralOpcodes(e); + } + add_CREATE_LIST((short) t.members().size()); + } else if (literal instanceof RecordLiteral r) { + for (var e : r.members().entrySet()) { + addLiteralOpcodes(e.getValue()); // value then key to make popping ordered + add_LOAD_CONST(e.getKey().toString()); + } + add_CREATE_MAP((short) r.members().size()); + } else if (literal instanceof BooleanLiteral b) { + add_LOAD_CONST(b.value().getValue()); + } else if (literal instanceof IntegerLiteral i) { + add_LOAD_CONST(i.toNode().expectNumberNode().getValue()); + } else { + throw new UnsupportedOperationException("Unexpected rules engine Literal type: " + literal); + } + } + + private void compileExpression(Expression expression) { + if (performOptimizations) { + var register = cse.get(expression); + if (register != null) { + add_LOAD_REGISTER(register); + return; + } + } + + alwaysCompileExpression(expression); + } + + private void alwaysCompileExpression(Expression expression) { + expression.accept(new ExpressionVisitor() { + @Override + public Void visitLiteral(Literal literal) { + addLiteralOpcodes(literal); + return null; + } + + @Override + public Void visitRef(Reference reference) { + var index = getRegister(reference.getName().toString()); + add_LOAD_REGISTER(index); + return null; + } + + @Override + public Void visitGetAttr(GetAttr getAttr) { + compileExpression(getAttr.getTarget()); + add_GET_ATTR(AttrExpression.from(getAttr)); + return null; + } + + @Override + public Void visitIsSet(Expression fn) { + if (fn instanceof Reference ref) { + add_TEST_REGISTER_ISSET(ref.getName().toString()); + } else { + compileExpression(fn); + add_ISSET(); + } + return null; + } + + @Override + public Void visitNot(Expression not) { + compileExpression(not); + add_NOT(); + return null; + } + + @Override + public Void visitBoolEquals(Expression left, Expression right) { + if (left instanceof BooleanLiteral b) { + pushBooleanOptimization(b, right); + } else if (right instanceof BooleanLiteral b) { + pushBooleanOptimization(b, left); + } else { + compileExpression(left); + compileExpression(right); + add_FN(getFunctionIndex("booleanEquals")); + } + return null; + } + + private void pushBooleanOptimization(BooleanLiteral b, Expression other) { + if (b.value().getValue() && other instanceof Reference ref) { + add_TEST_REGISTER_IS_TRUE(ref.getName().toString()); + } else { + compileExpression(other); + add_IS_TRUE(); + if (!b.value().getValue()) { + add_NOT(); + } + } + } + + @Override + public Void visitStringEquals(Expression left, Expression right) { + compileExpression(left); + compileExpression(right); + add_FN(getFunctionIndex("stringEquals")); + return null; + } + + @Override + public Void visitLibraryFunction(FunctionDefinition fn, List args) { + var index = getFunctionIndex(fn.getId()); + var f = usedFunctions.get(index); + // Detect if the runtime function differs from the defined trait function. + if (f.getOperandCount() != fn.getArguments().size()) { + throw new RulesEvaluationError("Rules engine function `" + fn.getId() + "` accepts " + + fn.getArguments().size() + " arguments in Smithy traits, but " + + f.getOperandCount() + " in the registered VM function."); + } + // Should never happen, but just in case. + if (fn.getArguments().size() != args.size()) { + throw new RulesEvaluationError("Required arguments not given for " + fn); + } + for (var arg : args) { + compileExpression(arg); + } + add_FN(index); + return null; + } + }); + } + + private void compileEndpointRule(EndpointRule rule) { + // Adds to stack: headers map, auth schemes map, URL. + var jump = compileConditions(rule); + var e = rule.getEndpoint(); + + // Add endpoint header instructions. + if (!e.getHeaders().isEmpty()) { + for (var entry : e.getHeaders().entrySet()) { + // Header values. Then header name. + for (var h : entry.getValue()) { + compileExpression(h); + } + // Process the N header values that are on the stack. + add_CREATE_LIST((short) entry.getValue().size()); + // Now the header name. + add_LOAD_CONST(entry.getKey()); + } + // Combine the N headers that are on the stack in the form of String followed by List. + add_CREATE_MAP((short) e.getHeaders().size()); + } + + // Add property instructions. + if (!e.getProperties().isEmpty()) { + for (var entry : e.getProperties().entrySet()) { + compileExpression(entry.getValue()); + add_LOAD_CONST(entry.getKey().toString()); + } + add_CREATE_MAP((short) e.getProperties().size()); + } + + // Compile the URL expression (could be a reference, template, etc). This must be the closest on the stack. + compileExpression(e.getUrl()); + // Add the set endpoint instruction. + add_RETURN_ENDPOINT(!e.getHeaders().isEmpty(), !e.getProperties().isEmpty()); + // Patch in the actual jump target for each condition so it skips over the endpoint rule. + jump.patchTarget(instructions, instructionSize); + } + + private void compileErrorRule(ErrorRule rule) { + var jump = compileConditions(rule); + compileExpression(rule.getError()); // error message + add_RETURN_ERROR(); + // Patch in the actual jump target for each condition so it skips over the error rule. + jump.patchTarget(instructions, instructionSize); + } + + RulesProgram buildProgram() { + // Fill in the register and temporary register sizes. + instructions[1] = (byte) (this.registry.size() - temporaryRegisters); + instructions[2] = (byte) temporaryRegisters; + var fns = new RulesFunction[usedFunctions.size()]; + usedFunctions.toArray(fns); + var constPool = new Object[this.constantPool.size()]; + constantPool.keySet().toArray(constPool); + return new RulesProgram( + extensions, + this.instructions, + 0, + instructionSize, + registry, + fns, + builtinProvider, + constPool); + } + + private static final class JumpIfFalsey { + final List instructionPointers = new ArrayList<>(); + + void addPatch(int position) { + instructionPointers.add(position); + } + + void patchTarget(byte[] instructions, int instructionSize) { + byte low = (byte) (instructionSize & 0xFF); + byte high = (byte) ((instructionSize >> 8) & 0xFF); + for (var position : instructionPointers) { + instructions[position] = low; + instructions[position + 1] = high; + } + } + } + + private void add_LOAD_CONST(Object value) { + var constant = getConstant(value); + if (constant < 256) { + addInstruction(RulesProgram.LOAD_CONST); + addInstruction((byte) constant); + } else { + addInstruction(RulesProgram.LOAD_CONST, constant); + } + } + + private void add_SET_REGISTER(byte register) { + addInstruction(RulesProgram.SET_REGISTER); + addInstruction(register); + } + + private void add_LOAD_REGISTER(byte register) { + addInstruction(RulesProgram.LOAD_REGISTER); + addInstruction(register); + } + + private void add_JUMP_IF_FALSEY(int target) { + addInstruction(RulesProgram.JUMP_IF_FALSEY, target); + } + + private void add_NOT() { + addInstruction(RulesProgram.NOT); + } + + private void add_ISSET() { + addInstruction(RulesProgram.ISSET); + } + + private void add_TEST_REGISTER_ISSET(String register) { + addInstruction(RulesProgram.TEST_REGISTER_ISSET); + addInstruction(getRegister(register)); + } + + private void add_RETURN_ERROR() { + addInstruction(RulesProgram.RETURN_ERROR); + } + + private void add_RETURN_ENDPOINT(boolean hasHeaders, boolean hasProperties) { + addInstruction(RulesProgram.RETURN_ENDPOINT); + byte packed = 0; + if (hasHeaders) { + packed |= 1; + } + if (hasProperties) { + packed |= 2; + } + addInstruction(packed); + } + + private void add_CREATE_LIST(int length) { + addInstruction(RulesProgram.CREATE_LIST); + addInstruction((byte) length); + } + + private void add_CREATE_MAP(int length) { + addInstruction(RulesProgram.CREATE_MAP); + addInstruction((byte) length); + } + + private void add_RESOLVE_TEMPLATE(StringTemplate template) { + addInstruction(RulesProgram.RESOLVE_TEMPLATE, getConstant(template)); + } + + private void add_FN(byte functionIndex) { + addInstruction(RulesProgram.FN); + addInstruction(functionIndex); + } + + private void add_GET_ATTR(AttrExpression expression) { + addInstruction(RulesProgram.GET_ATTR, getConstant(expression)); + } + + private void add_IS_TRUE() { + addInstruction(RulesProgram.IS_TRUE); + } + + private void add_TEST_REGISTER_IS_TRUE(String register) { + addInstruction(RulesProgram.TEST_REGISTER_IS_TRUE); + addInstruction(getRegister(register)); + } + + private void addInstruction(byte value) { + if (instructionSize >= instructions.length) { + // Double the size when needed. + byte[] newInstructions = new byte[instructions.length * 2]; + System.arraycopy(instructions, 0, newInstructions, 0, instructions.length); + instructions = newInstructions; + } + instructions[instructionSize++] = value; + } + + private void addInstruction(byte opcode, int value) { + addInstruction(opcode); + addInstruction((byte) 0); + addInstruction((byte) 0); + EndpointUtils.shortToTwoBytes(value, instructions, instructionSize - 2); + } +} diff --git a/client/client-rulesengine/src/main/java/software/amazon/smithy/java/client/rulesengine/RulesEngine.java b/client/client-rulesengine/src/main/java/software/amazon/smithy/java/client/rulesengine/RulesEngine.java new file mode 100644 index 000000000..6c6d55614 --- /dev/null +++ b/client/client-rulesengine/src/main/java/software/amazon/smithy/java/client/rulesengine/RulesEngine.java @@ -0,0 +1,206 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.client.rulesengine; + +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.ServiceLoader; +import java.util.function.BiFunction; +import software.amazon.smithy.java.context.Context; +import software.amazon.smithy.rulesengine.language.EndpointRuleSet; +import software.amazon.smithy.utils.SmithyUnstableApi; + +/** + * Compiles and loads a rules engine used to resolve endpoints based on Smithy's rules engine traits. + */ +public final class RulesEngine { + + static final List EXTENSIONS = new ArrayList<>(); + static { + for (var ext : ServiceLoader.load(RulesExtension.class)) { + EXTENSIONS.add(ext); + } + } + + private final List extensions = new ArrayList<>(); + private final Map functions = new LinkedHashMap<>(); + private final List> builtinProviders = new ArrayList<>(); + private boolean performOptimizations = true; + + public RulesEngine() { + // Always include the standard builtins, but after any explicitly given builtins. + builtinProviders.add(Stdlib::standardBuiltins); + + // Always include standard library functions. + for (var fn : Stdlib.values()) { + this.functions.put(fn.getFunctionName(), fn); + } + + for (var ext : EXTENSIONS) { + addExtension(ext); + } + } + + /** + * Register a function with the rules engine. + * + * @param fn Function to register. + * @return the RulesEngine. + */ + public RulesEngine addFunction(RulesFunction fn) { + functions.put(fn.getFunctionName(), fn); + return this; + } + + /** + * Register a builtin provider with the rules engine. + * + *

Providers that do not implement support for a builtin by name must return null, to allow for composing + * multiple providers and calling them one after the other. + * + * @param builtinProvider Provider to register. + * @return the RulesEngine. + */ + public RulesEngine addBuiltinProvider(BiFunction builtinProvider) { + if (builtinProvider != null) { + this.builtinProviders.add(builtinProvider); + } + return this; + } + + /** + * Manually add a RulesEngineExtension to the engine that injects functions and builtins. + * + * @param extension Extension to register. + * @return the RulesEngine. + */ + public RulesEngine addExtension(RulesExtension extension) { + extensions.add(extension); + addBuiltinProvider(extension.getBuiltinProvider()); + for (var f : extension.getFunctions()) { + addFunction(f); + } + return this; + } + + /** + * Call this method to disable optional optimizations, like eliminating common subexpressions. + * + *

This might be useful if the client will only make a single call on a simple ruleset. + * + * @return the RulesEngine. + */ + public RulesEngine disableOptimizations() { + performOptimizations = false; + return this; + } + + private BiFunction createBuiltinProvider() { + return (name, ctx) -> { + for (var provider : builtinProviders) { + var result = provider.apply(name, ctx); + if (result != null) { + return result; + } + } + return null; + }; + } + + /** + * Compile rules into a {@link RulesProgram}. + * + * @param rules Rules to compile. + * @return the compiled program. + */ + public RulesProgram compile(EndpointRuleSet rules) { + return new RulesCompiler(extensions, rules, functions, createBuiltinProvider(), performOptimizations).compile(); + } + + /** + * Creates a builder used to create a pre-compiled {@link RulesProgram}. + * + *

Warning: this method does little to no validation of the given program, the constant pool, or registers. + * It is up to you to ensure that these values are all correctly provided or else the rule evaluator will fail + * during evaluation, or provide unpredictable results. + * + * @return the builder. + */ + @SmithyUnstableApi + public PrecompiledBuilder precompiledBuilder() { + return new PrecompiledBuilder(); + } + + @SmithyUnstableApi + public final class PrecompiledBuilder { + private ByteBuffer bytecode; + private Object[] constantPool; + private List parameters = List.of(); + private String[] functionNames; + + public PrecompiledBuilder bytecode(ByteBuffer bytecode) { + this.bytecode = bytecode; + return this; + } + + public PrecompiledBuilder bytecode(byte... bytes) { + return bytecode(ByteBuffer.wrap(bytes)); + } + + public PrecompiledBuilder constantPool(Object... constantPool) { + this.constantPool = constantPool; + return this; + } + + public PrecompiledBuilder parameters(ParamDefinition... paramDefinitions) { + this.parameters = Arrays.asList(paramDefinitions); + return this; + } + + public PrecompiledBuilder functionNames(String... functionNames) { + this.functionNames = functionNames; + return this; + } + + public RulesProgram build() { + Objects.requireNonNull(bytecode, "Missing bytecode for program"); + if (constantPool == null) { + constantPool = new Object[0]; + } + + RulesFunction[] indexedFunctions; + if (functionNames == null) { + indexedFunctions = new RulesFunction[0]; + } else { + // Load the ordered list of functions and fail if any are missing. + indexedFunctions = new RulesFunction[functionNames.length]; + int i = 0; + for (var f : functionNames) { + var func = functions.get(f); + if (func == null) { + throw new UnsupportedOperationException("Rules engine program requires missing function: " + f); + } + indexedFunctions[i++] = func; + } + } + + return new RulesProgram( + extensions, + bytecode.array(), + bytecode.arrayOffset() + bytecode.position(), + bytecode.remaining(), + parameters, + indexedFunctions, + createBuiltinProvider(), + constantPool); + } + } +} diff --git a/client/client-rulesengine/src/main/java/software/amazon/smithy/java/client/rulesengine/RulesEvaluationError.java b/client/client-rulesengine/src/main/java/software/amazon/smithy/java/client/rulesengine/RulesEvaluationError.java new file mode 100644 index 000000000..5e57fd1f9 --- /dev/null +++ b/client/client-rulesengine/src/main/java/software/amazon/smithy/java/client/rulesengine/RulesEvaluationError.java @@ -0,0 +1,19 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.client.rulesengine; + +/** + * An error encountered while running the rules engine. + */ +public class RulesEvaluationError extends RuntimeException { + public RulesEvaluationError(String message) { + super(message); + } + + public RulesEvaluationError(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/client/client-rulesengine/src/main/java/software/amazon/smithy/java/client/rulesengine/RulesExtension.java b/client/client-rulesengine/src/main/java/software/amazon/smithy/java/client/rulesengine/RulesExtension.java new file mode 100644 index 000000000..22d35c66d --- /dev/null +++ b/client/client-rulesengine/src/main/java/software/amazon/smithy/java/client/rulesengine/RulesExtension.java @@ -0,0 +1,52 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.client.rulesengine; + +import java.util.List; +import java.util.Map; +import java.util.function.BiFunction; +import software.amazon.smithy.java.client.core.endpoint.Endpoint; +import software.amazon.smithy.java.context.Context; + +/** + * An SPI used to extend the rules engine with custom builtins and functions. + */ +public interface RulesExtension { + /** + * Provides custom builtin values that are used to initialize parameters. + * + * @return the builtin provider or null if there is no custom provider in this extension. + */ + default BiFunction getBuiltinProvider() { + return null; + } + + /** + * Gets a list of the custom functions to register with the VM. + * + * @return the list of functions to register. + */ + default List getFunctions() { + return List.of(); + } + + /** + * Allows processing a resolved endpoint, extracting properties, and updating the endpoint builder. + * + * @param builder The endpoint being created. Modify this based on properties and headers. + * @param context The context provided when resolving the endpoint. The endpoint has its own context properties. + * @param properties The raw properties returned from the endpoint resolver. Process these to update the builder. + * @param headers The headers returned from the endpoint resolver. Process these if needed. + */ + default void extractEndpointProperties( + Endpoint.Builder builder, + Context context, + Map properties, + Map> headers + ) { + // by default does nothing. + } +} diff --git a/client/client-rulesengine/src/main/java/software/amazon/smithy/java/client/rulesengine/RulesFunction.java b/client/client-rulesengine/src/main/java/software/amazon/smithy/java/client/rulesengine/RulesFunction.java new file mode 100644 index 000000000..b3bc2b0a0 --- /dev/null +++ b/client/client-rulesengine/src/main/java/software/amazon/smithy/java/client/rulesengine/RulesFunction.java @@ -0,0 +1,69 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.client.rulesengine; + +/** + * Implements a function that can be used in the rules engine. + */ +public interface RulesFunction { + /** + * Get the number of operands the function requires. + * + *

The function will be called with this many values. + * + * @return the number of operands. + */ + int getOperandCount(); + + /** + * Get the name of the function. + * + * @return the function name. + */ + String getFunctionName(); + + /** + * Apply the function to the given N operands and returns the result or null. + * + *

This is called when an operation has more than two operands. + * + * @param operands Operands to process. + * @return the result of the function or null. + */ + default Object apply(Object... operands) { + throw new IllegalArgumentException("Invalid number of arguments: " + operands.length); + } + + /** + * Calls a function that has zero operands. + * + * @return the result of the function or null. + */ + default Object apply0() { + throw new IllegalArgumentException("Invalid number of arguments: 0"); + } + + /** + * Calls a function that has one operand. + * + * @param arg1 Operand to process. + * @return the result of the function or null. + */ + default Object apply1(Object arg1) { + throw new IllegalArgumentException("Invalid number of arguments: 1"); + } + + /** + * Calls a function that has two operands. + * + * @param arg1 Operand to process. + * @param arg2 Operand to process. + * @return the result of the function or null. + */ + default Object apply2(Object arg1, Object arg2) { + throw new IllegalArgumentException("Invalid number of arguments: 2"); + } +} diff --git a/client/client-rulesengine/src/main/java/software/amazon/smithy/java/client/rulesengine/RulesProgram.java b/client/client-rulesengine/src/main/java/software/amazon/smithy/java/client/rulesengine/RulesProgram.java new file mode 100644 index 000000000..fc414a848 --- /dev/null +++ b/client/client-rulesengine/src/main/java/software/amazon/smithy/java/client/rulesengine/RulesProgram.java @@ -0,0 +1,408 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.client.rulesengine; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.BiFunction; +import software.amazon.smithy.java.client.core.endpoint.Endpoint; +import software.amazon.smithy.java.context.Context; +import software.amazon.smithy.utils.SmithyUnstableApi; + +/** + * A compiled and ready to run rules engine program. + * + *

A RulesProgram can be run any number of times and is thread-safe. A program can be serialized and later restored + * using {@code ToString}. A RulesProgram is created using a {@link RulesEngine}. + */ +public final class RulesProgram { + /** + * The version that a rules engine program was compiled with. The version of a program must be less than or equal + * to this version number. That is, older code can be run, but newer code cannot. The version is only incremented + * when things like new opcodes are added. This is a single byte that appears as the first byte in the rules + * engine bytecode. The version is a negative number to prevent accidentally treating another opcode as the version. + */ + public static final byte VERSION = -1; + + /** + * Push a value onto the stack. Must be followed by one unsigned byte representing the constant pool index. + */ + static final byte LOAD_CONST = 0; + + /** + * Push a value onto the stack. Must be followed by two bytes representing the (short) constant pool index. + */ + static final byte LOAD_CONST_W = 1; + + /** + * Peeks the value at the top of the stack and pushes it onto the register stack of a register. Must be followed + * by the one byte register index. + */ + static final byte SET_REGISTER = 2; + + /** + * Get the value of a register and push it onto the stack. Must be followed by the one byte register index. + */ + static final byte LOAD_REGISTER = 3; + + /** + * Jumps to an opcode index if the top of the stack is null or false. Must be followed by two bytes representing + * a short index position of the bytecode address. + */ + static final byte JUMP_IF_FALSEY = 4; + + /** + * Pops a value off the stack and pushes true if it is falsey (null or false), or false if not. + * + *

This implements the "not" function as an opcode. + */ + static final byte NOT = 5; + + /** + * Pops a value off the stack and pushes true if it is set (that is, not null). + * + *

This implements the "isset" function as an opcode. + */ + static final byte ISSET = 6; + + /** + * Checks if a register is set to something that is boolean true or a non null value. + * + *

Must be followed by an unsigned byte that represents the register to check. + */ + static final byte TEST_REGISTER_ISSET = 7; + + /** + * Sets an error on the VM and exits. + * + *

Pops a single value that provides the error string to set. + */ + static final byte RETURN_ERROR = 8; + + /** + * Sets the endpoint result of the VM and exits. Pops the top of the stack, expecting a string value. The opcode + * must be followed by a byte where the first bit of the byte is on if the endpoint has headers, and the second + * bit is on if the endpoint has properties. + */ + static final byte RETURN_ENDPOINT = 9; + + /** + * Pops N values off the stack and pushes a list of those values onto the stack. Must be followed by an unsigned + * byte that defines the number of elements in the list. + */ + static final byte CREATE_LIST = 10; + + /** + * Pops N*2 values off the stack (key then value), creates a map of those values, and pushes the map onto the + * stack. Each popped key must be a string. Must be followed by an unsigned byte that defines the + * number of entries in the map. + */ + static final byte CREATE_MAP = 11; + + /** + * Resolves a template string. Must be followed by two bytes, a short, that represents the constant pool index + * that stores the StringTemplate. + * + *

The corresponding instruction has a StringTemplate that tells the VM how many values to pop off the stack. + * The popped values fill in values into the template. The resolved template value as a string is then pushed onto + * the stack. + */ + static final byte RESOLVE_TEMPLATE = 12; + + /** + * Calls a function. Must be followed by a byte to provide the function index to call. + * + *

The function pops zero or more values off the stack based on the RulesFunction registered for the index, + * and then pushes the Object result onto the stack. + */ + static final byte FN = 13; + + /** + * Pops the top level value and applies a getAttr expression on it, pushing the result onto the stack. + * + *

Must be followed by two bytes, a short, that represents the constant pool index that stores the + * AttrExpression. + */ + static final byte GET_ATTR = 14; + + /** + * Pops a value and pushes true if the value is boolean true, false if not. + */ + static final byte IS_TRUE = 15; + + /** + * Checks if a register is boolean true and pushes the result onto the stack. + * + *

Must be followed by a byte that represents the register to check. + */ + static final byte TEST_REGISTER_IS_TRUE = 16; + + /** + * Pops the value at the top of the stack and returns it from the VM. This can be used for testing purposes or + * for returning things other than endpoint values. + */ + static final byte RETURN_VALUE = 17; + + final List extensions; + final Object[] constantPool; + final byte[] instructions; + final int instructionOffset; + final int instructionSize; + final ParamDefinition[] registerDefinitions; + final RulesFunction[] functions; + private final BiFunction builtinProvider; + private final int paramCount; // number of provided params. + + RulesProgram( + List extensions, + byte[] instructions, + int instructionOffset, + int instructionSize, + List params, + RulesFunction[] functions, + BiFunction builtinProvider, + Object[] constantPool + ) { + this.extensions = extensions; + this.instructions = instructions; + this.instructionOffset = instructionOffset; + this.instructionSize = instructionSize; + this.functions = functions; + this.builtinProvider = builtinProvider; + this.constantPool = constantPool; + + if (instructionSize < 3) { + throw new IllegalArgumentException("Invalid rules engine bytecode: too short"); + } + + var versionByte = instructions[instructionOffset]; + if (versionByte >= 0) { + throw new IllegalArgumentException("Invalid rules engine bytecode: missing version byte."); + } + + if (versionByte < VERSION) { + throw new IllegalArgumentException(String.format( + "Invalid rules engine bytecode: unsupported bytecode version %d. Up to version %d is supported." + + "Perhaps you need to update the client-rulesengine package.", + -versionByte, + -VERSION)); + } + + paramCount = instructions[instructionOffset + 1] & 0xFF; + var syntheticParamCount = instructions[instructionOffset + 2] & 0xFF; + var totalParams = paramCount + syntheticParamCount; + registerDefinitions = new ParamDefinition[totalParams]; + + if (params.size() == paramCount) { + // Given just params and not registers too. + params.toArray(registerDefinitions); + for (var i = 0; i < syntheticParamCount; i++) { + registerDefinitions[paramCount + i] = new ParamDefinition("r" + i); + } + } else if (params.size() == totalParams) { + // Given exactly the required number of parameters. Assume it was given the params and registers. + params.toArray(registerDefinitions); + } else { + throw new IllegalArgumentException("Invalid rules engine bytecode: bytecode requires " + paramCount + + " parameters, but provided " + params.size()); + } + } + + /** + * Runs the rules engine program and resolves an endpoint. + * + * @param context Context used during evaluation. + * @param parameters Rules engine parameters. + * @return the resolved Endpoint. + * @throws RulesEvaluationError if the program fails during evaluation. + */ + public Endpoint resolveEndpoint(Context context, Map parameters) { + return run(context, parameters); + } + + /** + * Runs the rules engine program. + * + * @param context Context used during evaluation. + * @param parameters Rules engine parameters. + * @return the rules engine result. + * @throws RulesEvaluationError if the program fails during evaluation. + */ + public T run(Context context, Map parameters) { + for (var e : parameters.entrySet()) { + EndpointUtils.verifyObject(e.getValue()); + } + var vm = new RulesVm(context, this, parameters, builtinProvider); + return vm.evaluate(); + } + + /** + * Get the program's content pool. + * + * @return the constant pool. Do not modify. + */ + @SmithyUnstableApi + public Object[] getConstantPool() { + return constantPool; + } + + /** + * Get the program's parameters. + * + * @return the parameters. + */ + @SmithyUnstableApi + public List getParamDefinitions() { + List result = new ArrayList<>(); + for (var i = 0; i < paramCount; i++) { + result.add(registerDefinitions[i]); + } + return result; + } + + @Override + public String toString() { + StringBuilder s = new StringBuilder(); + + // Write the registry values in index order. + if (registerDefinitions.length > 0) { + s.append("Registers:\n"); + int i = 0; + for (var r : registerDefinitions) { + s.append(" ").append(i++).append(": "); + s.append(r); + s.append("\n"); + } + s.append("\n"); + } + + if (constantPool.length > 0) { + s.append("Constants:\n"); + var i = 0; + for (var c : constantPool) { + s.append(" ").append(i++).append(": "); + if (c instanceof StringTemplate) { + s.append("Template"); + } else if (c instanceof AttrExpression) { + s.append("AttrExpression"); + } else { + s.append(c.getClass().getSimpleName()); + } + s.append(": ").append(c).append("\n"); + } + s.append("\n"); + } + + // Write the required function names, in index order. + if (functions.length > 0) { + var i = 0; + s.append("Functions:\n"); + for (var f : functions) { + s.append(" ").append(i++).append(": ").append(f.getFunctionName()).append("\n"); + } + s.append("\n"); + } + + // Write the instructions. + s.append("Instructions: (version=").append(-instructions[instructionOffset]).append(")\n"); + // Skip version, param count, synthetic param count bytes. + for (var i = instructionOffset + 3; i < instructionSize; i++) { + s.append(" "); + s.append(String.format("%03d", i)); + s.append(": "); + + var skip = 0; + var name = switch (instructions[i]) { + case LOAD_CONST -> { + skip = 1; + yield "LOAD_CONST"; + } + case LOAD_CONST_W -> { + skip = 2; + yield "LOAD_CONST_W"; + } + case SET_REGISTER -> { + skip = 1; + yield "SET_REGISTER"; + } + case LOAD_REGISTER -> { + skip = 1; + yield "LOAD_REGISTER"; + } + case JUMP_IF_FALSEY -> { + skip = 2; + yield "JUMP_IF_FALSEY"; + } + case NOT -> "NOT"; + case ISSET -> "ISSET"; + case TEST_REGISTER_ISSET -> { + skip = 1; + yield "TEST_REGISTER_SET"; + } + case RETURN_ERROR -> "RETURN_ERROR"; + case RETURN_ENDPOINT -> { + skip = 1; + yield "RETURN_ENDPOINT"; + } + case CREATE_LIST -> { + skip = 1; + yield "CREATE_LIST"; + } + case CREATE_MAP -> { + skip = 1; + yield "CREATE_MAP"; + } + case RESOLVE_TEMPLATE -> { + skip = 2; + yield "RESOLVE_TEMPLATE"; + } + case FN -> { + skip = 1; + yield "FN"; + } + case GET_ATTR -> { + skip = 2; + yield "GET_ATTR"; + } + case IS_TRUE -> "IS_TRUE"; + case TEST_REGISTER_IS_TRUE -> { + skip = 1; + yield "TEST_REGISTER_IS_TRUE"; + } + case RETURN_VALUE -> "RETURN_VALUE"; + default -> "?" + instructions[i]; + }; + + switch (skip) { + case 0 -> s.append(name); + case 1 -> { + s.append(String.format("%-22s ", name)); + if (instructions.length > i + 1) { + s.append(instructions[i + 1]); + } else { + s.append("?"); + } + i++; + } + default -> { + // it's a two-byte unsigned short. + s.append(String.format("%-22s ", name)); + if (instructions.length > i + 2) { + s.append(EndpointUtils.bytesToShort(instructions, i + 1)); + } else { + s.append("??"); + } + i += 2; + } + } + + s.append("\n"); + } + + return s.toString(); + } +} diff --git a/client/client-rulesengine/src/main/java/software/amazon/smithy/java/client/rulesengine/RulesVm.java b/client/client-rulesengine/src/main/java/software/amazon/smithy/java/client/rulesengine/RulesVm.java new file mode 100644 index 000000000..103f65402 --- /dev/null +++ b/client/client-rulesengine/src/main/java/software/amazon/smithy/java/client/rulesengine/RulesVm.java @@ -0,0 +1,318 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.client.rulesengine; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.BiFunction; +import software.amazon.smithy.java.client.core.endpoint.Endpoint; +import software.amazon.smithy.java.client.core.endpoint.EndpointContext; +import software.amazon.smithy.java.context.Context; + +final class RulesVm { + + // Make number of URIs to cache in the thread-local cache. + private static final int MAX_CACHE_SIZE = 32; + + // Caches up to 32 previously parsed URIs in a thread-local LRU cache. + private static final ThreadLocal> URI_LRU_CACHE = ThreadLocal.withInitial(() -> { + return new LinkedHashMap<>(16, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > MAX_CACHE_SIZE; + } + }; + }); + + // Minimum size for temp arrays when it's lazily allocated. + private static final int MIN_TEMP_ARRAY_SIZE = 8; + + // Temp array used during evaluation. + private Object[] tempArray = new Object[8]; + private int tempArraySize = 8; + + private final Context context; + private final RulesProgram program; + private final Object[] registers; + private final BiFunction builtinProvider; + private final byte[] instructions; + private Object[] stack = new Object[8]; + private int stackPosition = 0; + private int pc; + + RulesVm( + Context context, + RulesProgram program, + Map parameters, + BiFunction builtinProvider + ) { + this.context = context; + this.program = program; + this.instructions = program.instructions; + this.builtinProvider = builtinProvider; + + // Copy the registers to not continuously push to their stack. + registers = new Object[program.registerDefinitions.length]; + for (var i = 0; i < program.registerDefinitions.length; i++) { + var definition = program.registerDefinitions[i]; + var provided = parameters.get(definition.name()); + if (provided != null) { + registers[i] = provided; + } else { + initializeRegister(context, i, definition); + } + } + } + + @SuppressWarnings("unchecked") + T evaluate() { + try { + return (T) run(); + } catch (ClassCastException e) { + throw createError("Unexpected value type encountered while evaluating rules engine", e); + } catch (ArrayIndexOutOfBoundsException e) { + throw createError("Malformed bytecode encountered while evaluating rules engine", e); + } + } + + private RulesEvaluationError createError(String message, RuntimeException e) { + var report = message + ". Encountered at address " + pc + " of program:\n" + program; + throw new RulesEvaluationError(report, e); + } + + void initializeRegister(Context context, int index, ParamDefinition definition) { + if (definition.defaultValue() != null) { + registers[index] = definition.defaultValue(); + return; + } + + if (definition.builtin() != null) { + var builtinValue = builtinProvider.apply(definition.builtin(), context); + if (builtinValue != null) { + registers[index] = builtinValue; + return; + } + } + + if (definition.required()) { + throw new RulesEvaluationError("Required rules engine parameter missing: " + definition.name()); + } + } + + private void push(Object value) { + if (stackPosition == stack.length) { + resizeStack(); + } + stack[stackPosition++] = value; + } + + private void resizeStack() { + int newCapacity = stack.length + (stack.length >> 1); + Object[] newStack = new Object[newCapacity]; + System.arraycopy(stack, 0, newStack, 0, stack.length); + stack = newStack; + } + + private Object pop() { + return stack[--stackPosition]; // no need to clear out the memory since it's tied to lifetime of the VM. + } + + private Object peek() { + return stack[stackPosition - 1]; + } + + // Reads the next two bytes in little-endian order. + private int readUnsignedShort(int position) { + return EndpointUtils.bytesToShort(instructions, position); + } + + private Object run() { + var instructionSize = program.instructionSize; + var constantPool = program.constantPool; + var instructions = this.instructions; + var registers = this.registers; + + // Skip version, params, and register bytes. + for (pc = program.instructionOffset + 3; pc < instructionSize; pc++) { + switch (instructions[pc]) { + case RulesProgram.LOAD_CONST -> push(constantPool[instructions[++pc] & 0xFF]); // read unsigned byte + case RulesProgram.LOAD_CONST_W -> { + push(constantPool[readUnsignedShort(pc + 1)]); // read unsigned short + pc += 2; + } + case RulesProgram.SET_REGISTER -> registers[instructions[++pc] & 0xFF] = peek(); // read unsigned byte + case RulesProgram.LOAD_REGISTER -> push(registers[instructions[++pc] & 0xFF]); // read unsigned byte + case RulesProgram.JUMP_IF_FALSEY -> { + Object value = pop(); + if (value == null || Boolean.FALSE.equals(value)) { + pc = readUnsignedShort(pc + 1) - 1; // -1 because loop will increment + } else { + pc += 2; + } + } + case RulesProgram.NOT -> push(pop() != Boolean.TRUE); + case RulesProgram.ISSET -> { + Object value = pop(); + // Push true if it's set and not a boolean, or boolean true. + push(value != null && !Boolean.FALSE.equals(value)); + } + case RulesProgram.TEST_REGISTER_ISSET -> { + var value = registers[instructions[++pc] & 0xFF]; // read unsigned byte + push(value != null && !Boolean.FALSE.equals(value)); + } + case RulesProgram.RETURN_ERROR -> { + throw new RulesEvaluationError((String) pop()); + } + case RulesProgram.RETURN_ENDPOINT -> { + return setEndpoint(instructions[++pc]); + } + case RulesProgram.CREATE_LIST -> createList(instructions[++pc] & 0xFF); // read unsigned byte + case RulesProgram.CREATE_MAP -> createMap(instructions[++pc] & 0xFF); // read unsigned byte + case RulesProgram.RESOLVE_TEMPLATE -> { + resolveTemplate((StringTemplate) constantPool[readUnsignedShort(pc + 1)]); + pc += 2; + } + case RulesProgram.FN -> { + var fn = program.functions[instructions[++pc] & 0xFF]; // read unsigned byte + push(switch (fn.getOperandCount()) { + case 0 -> fn.apply0(); + case 1 -> fn.apply1(pop()); + case 2 -> { + Object b = pop(); + Object a = pop(); + yield fn.apply2(a, b); + } + default -> { + // Pop arguments from stack in reverse order. + var temp = getTempArray(fn.getOperandCount()); + for (int i = fn.getOperandCount() - 1; i >= 0; i--) { + temp[i] = pop(); + } + yield fn.apply(temp); + } + }); + } + case RulesProgram.GET_ATTR -> { + var constant = readUnsignedShort(pc + 1); + AttrExpression getAttr = (AttrExpression) constantPool[constant]; + var target = pop(); + push(getAttr.apply(target)); + pc += 2; + } + case RulesProgram.IS_TRUE -> push(pop() == Boolean.TRUE); + case RulesProgram.TEST_REGISTER_IS_TRUE -> { + int register = instructions[++pc] & 0xFF; // read unsigned byte + push(registers[register] == Boolean.TRUE); + } + case RulesProgram.RETURN_VALUE -> { + return pop(); + } + default -> { + throw new RulesEvaluationError("Unknown rules engine instruction: " + instructions[pc]); + } + } + } + + throw new RulesEvaluationError("No value returned from rules engine"); + } + + private void createMap(int size) { + push(switch (size) { + case 0 -> Map.of(); + case 1 -> Map.of((String) pop(), pop()); + case 2 -> Map.of((String) pop(), pop(), (String) pop(), pop()); + case 3 -> Map.of((String) pop(), pop(), (String) pop(), pop(), (String) pop(), pop()); + default -> { + Map map = new HashMap<>((int) (size / 0.75f) + 1); // Avoid rehashing + for (var i = 0; i < size; i++) { + map.put((String) pop(), pop()); + } + yield map; + } + }); + } + + private void createList(int size) { + push(switch (size) { + case 0 -> List.of(); + case 1 -> Collections.singletonList(pop()); + default -> { + var values = new Object[size]; + for (var i = size - 1; i >= 0; i--) { + values[i] = pop(); + } + yield Arrays.asList(values); + } + }); + } + + private void resolveTemplate(StringTemplate template) { + var expressionCount = template.expressionCount(); + var temp = getTempArray(expressionCount); + for (var i = 0; i < expressionCount; i++) { + temp[i] = pop(); + } + push(template.resolve(expressionCount, temp)); + } + + @SuppressWarnings("unchecked") + private Endpoint setEndpoint(byte packed) { + boolean hasHeaders = (packed & 1) != 0; + boolean hasProperties = (packed & 2) != 0; + var urlString = (String) pop(); + var properties = (Map) (hasProperties ? pop() : Map.of()); + var headers = (Map>) (hasHeaders ? pop() : Map.of()); + var builder = Endpoint.builder().uri(createUri(urlString)); + + if (!headers.isEmpty()) { + builder.putProperty(EndpointContext.HEADERS, headers); + } + + for (var extension : program.extensions) { + extension.extractEndpointProperties(builder, context, properties, headers); + } + + return builder.build(); + } + + public static URI createUri(String uriStr) { + var cache = URI_LRU_CACHE.get(); + var uri = cache.get(uriStr); + if (uri == null) { + try { + uri = new URI(uriStr); + } catch (URISyntaxException e) { + throw new RulesEvaluationError("Error creating URI: " + e.getMessage(), e); + } + cache.put(uriStr, uri); + } + return uri; + } + + private Object[] getTempArray(int requiredSize) { + if (tempArraySize < requiredSize) { + resizeTempArray(requiredSize); + } + return tempArray; + } + + private void resizeTempArray(int requiredSize) { + // Resize to a power of two. + int newSize = MIN_TEMP_ARRAY_SIZE; + while (newSize < requiredSize) { + newSize <<= 1; + } + + tempArray = new Object[newSize]; + tempArraySize = newSize; + } +} diff --git a/client/client-rulesengine/src/main/java/software/amazon/smithy/java/client/rulesengine/Stdlib.java b/client/client-rulesengine/src/main/java/software/amazon/smithy/java/client/rulesengine/Stdlib.java new file mode 100644 index 000000000..83d727cf1 --- /dev/null +++ b/client/client-rulesengine/src/main/java/software/amazon/smithy/java/client/rulesengine/Stdlib.java @@ -0,0 +1,116 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.client.rulesengine; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Objects; +import software.amazon.smithy.java.client.core.ClientContext; +import software.amazon.smithy.java.context.Context; +import software.amazon.smithy.java.io.uri.URLEncoding; +import software.amazon.smithy.rulesengine.language.syntax.expressions.functions.IsValidHostLabel; +import software.amazon.smithy.rulesengine.language.syntax.expressions.functions.Substring; + +/** + * Implements stdlib functions of the rules engine that weren't promoted to opcodes (GetAttr, isset, not). + */ +enum Stdlib implements RulesFunction { + // https://smithy.io/2.0/additional-specs/rules-engine/standard-library.html#stringequals-function + STRING_EQUALS("stringEquals", 2) { + @Override + public Object apply2(Object a, Object b) { + return Objects.equals(EndpointUtils.castFnArgument(a, String.class, "stringEquals", 1), + EndpointUtils.castFnArgument(b, String.class, "stringEquals", 2)); + } + }, + + // https://smithy.io/2.0/additional-specs/rules-engine/standard-library.html#booleanequals-function + BOOLEAN_EQUALS("booleanEquals", 2) { + @Override + public Object apply2(Object a, Object b) { + return Objects.equals(EndpointUtils.castFnArgument(a, Boolean.class, "booleanEquals", 1), + EndpointUtils.castFnArgument(b, Boolean.class, "booleanEquals", 2)); + } + }, + + // https://smithy.io/2.0/additional-specs/rules-engine/standard-library.html#substring-function + SUBSTRING("substring", 4) { + @Override + public Object apply(Object... operands) { + // software.amazon.smithy.rulesengine.language.syntax.expressions.functions.Substring.Definition.evaluate + String str = EndpointUtils.castFnArgument(operands[0], String.class, "substring", 1); + int startIndex = EndpointUtils.castFnArgument(operands[1], Integer.class, "substring", 2); + int stopIndex = EndpointUtils.castFnArgument(operands[2], Integer.class, "substring", 3); + boolean reverse = EndpointUtils.castFnArgument(operands[3], Boolean.class, "substring", 4); + return Substring.getSubstring(str, startIndex, stopIndex, reverse); + } + }, + + // https://smithy.io/2.0/additional-specs/rules-engine/standard-library.html#isvalidhostlabel-function + IS_VALID_HOST_LABEL("isValidHostLabel", 2) { + @Override + public Object apply2(Object arg1, Object arg2) { + var hostLabel = EndpointUtils.castFnArgument(arg1, String.class, "isValidHostLabel", 1); + var allowDots = EndpointUtils.castFnArgument(arg2, Boolean.class, "isValidHostLabel", 2); + return IsValidHostLabel.isValidHostLabel(hostLabel, Boolean.TRUE.equals(allowDots)); + } + }, + + // https://smithy.io/2.0/additional-specs/rules-engine/standard-library.html#parseurl-function + PARSE_URL("parseURL", 1) { + @Override + public Object apply1(Object arg) { + try { + var result = new URI(EndpointUtils.castFnArgument(arg, String.class, "parseURL", 1)); + if (null != result.getRawQuery()) { + // "If the URL given contains a query portion, the URL MUST be rejected and the function MUST + // return an empty optional." + return null; + } + return result; + } catch (URISyntaxException e) { + throw new RulesEvaluationError("Error parsing URI in endpoint rule parseURL method", e); + } + } + }, + + // https://smithy.io/2.0/additional-specs/rules-engine/standard-library.html#uriencode-function + URI_ENCODE("uriEncode", 1) { + @Override + public Object apply1(Object arg) { + var str = EndpointUtils.castFnArgument(arg, String.class, "uriEncode", 1); + return URLEncoding.encodeUnreserved(str, false); + } + }; + + private final String name; + private final int operands; + + Stdlib(String name, int operands) { + this.name = name; + this.operands = operands; + } + + @Override + public int getOperandCount() { + return operands; + } + + @Override + public String getFunctionName() { + return name; + } + + static Object standardBuiltins(String name, Context context) { + if (name.equals("SDK::Endpoint")) { + var result = context.get(ClientContext.CUSTOM_ENDPOINT); + if (result != null) { + return result.uri().toString(); + } + } + return null; + } +} diff --git a/client/client-rulesengine/src/main/java/software/amazon/smithy/java/client/rulesengine/StringTemplate.java b/client/client-rulesengine/src/main/java/software/amazon/smithy/java/client/rulesengine/StringTemplate.java new file mode 100644 index 000000000..f5844d314 --- /dev/null +++ b/client/client-rulesengine/src/main/java/software/amazon/smithy/java/client/rulesengine/StringTemplate.java @@ -0,0 +1,119 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.client.rulesengine; + +import java.util.Arrays; +import java.util.Objects; +import java.util.function.Consumer; +import software.amazon.smithy.rulesengine.language.evaluation.value.Value; +import software.amazon.smithy.rulesengine.language.syntax.expressions.Expression; +import software.amazon.smithy.rulesengine.language.syntax.expressions.Template; + +/** + * Similar to {@link Template}, but built around Object instead of {@link Value}. + */ +final class StringTemplate { + + private static final ThreadLocal STRING_BUILDER = ThreadLocal.withInitial( + () -> new StringBuilder(64)); + + private final String template; + private final Object[] parts; + private final int expressionCount; + private final Expression singularExpression; + + StringTemplate(String template, Object[] parts, int expressionCount, Expression singularExpression) { + this.template = template; + this.parts = parts; + this.expressionCount = expressionCount; + this.singularExpression = singularExpression; + } + + int expressionCount() { + return expressionCount; + } + + Expression singularExpression() { + return singularExpression; + } + + /** + * Calls a consumer for every expression in the template. + * + * @param consumer consumer that accepts each expression. + */ + void forEachExpression(Consumer consumer) { + for (int i = parts.length - 1; i >= 0; i--) { + var part = parts[i]; + if (part instanceof Expression e) { + consumer.accept(e); + } + } + } + + String resolve(int arraySize, Object[] strings) { + if (arraySize != expressionCount) { + throw new RulesEvaluationError("Missing template parameters for a string template `" + + template + "`. Given: [" + Arrays.asList(strings) + ']'); + } + + var result = STRING_BUILDER.get(); + result.setLength(0); + int paramIndex = 0; + for (var part : parts) { + if (part == null) { + throw new RulesEvaluationError("Missing part of template " + template + " at part " + paramIndex); + } else if (part.getClass() == String.class) { + result.append((String) part); // we know parts are either strings or Expressions. + } else { + result.append(strings[paramIndex++]); + } + } + + return result.toString(); + } + + static StringTemplate from(Template template) { + var templateParts = template.getParts(); + Object[] parts = new Object[templateParts.size()]; + int expressionCount = 0; + for (var i = 0; i < templateParts.size(); i++) { + var part = templateParts.get(i); + if (part instanceof Template.Dynamic d) { + expressionCount++; + parts[i] = d.toExpression(); + } else { + parts[i] = part.toString(); + } + } + var singularExpression = (expressionCount == 1 && parts.length == 1) ? (Expression) parts[0] : null; + return new StringTemplate(template.toString(), parts, expressionCount, singularExpression); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } else if (o == null || getClass() != o.getClass()) { + return false; + } else { + StringTemplate that = (StringTemplate) o; + return expressionCount == that.expressionCount + && Objects.equals(template, that.template) + && Objects.deepEquals(parts, that.parts); + } + } + + @Override + public int hashCode() { + return Objects.hash(template, Arrays.hashCode(parts)); + } + + @Override + public String toString() { + return "StringTemplate[template=\"" + template + "\"]"; + } +} diff --git a/client/client-rulesengine/src/test/java/software/amazon/smithy/java/client/rulesengine/AttrExpressionTest.java b/client/client-rulesengine/src/test/java/software/amazon/smithy/java/client/rulesengine/AttrExpressionTest.java new file mode 100644 index 000000000..52a058578 --- /dev/null +++ b/client/client-rulesengine/src/test/java/software/amazon/smithy/java/client/rulesengine/AttrExpressionTest.java @@ -0,0 +1,48 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.client.rulesengine; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; + +import java.net.URI; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +public class AttrExpressionTest { + @ParameterizedTest + @MethodSource("getAttrProvider") + public void getsAttr(String template, Object value, Object expected) { + var getAttr = AttrExpression.parse(template); + var result = getAttr.apply(value); + + assertThat(template, result, equalTo(expected)); + assertThat(template, equalTo(getAttr.toString())); + } + + public static List getAttrProvider() throws Exception { + Map mapWithNull = new HashMap<>(); + mapWithNull.put("foo", null); + + return List.of( + Arguments.of("foo", Map.of("foo", "bar"), "bar"), + Arguments.of("foo.bar", Map.of("foo", Map.of("bar", "baz")), "baz"), + Arguments.of("foo.bar.baz", Map.of("foo", Map.of("bar", Map.of("baz", "qux"))), "qux"), + Arguments.of("foo.bar[0]", Map.of("foo", Map.of("bar", List.of("baz"))), "baz"), + Arguments.of("foo[0]", Map.of("foo", List.of("bar")), "bar"), + Arguments.of("foo", Map.of("foo", "bar"), "bar"), + Arguments.of("isIp", new URI("https://localhost:8080"), false), + Arguments.of("scheme", new URI("https://localhost:8080"), "https"), + Arguments.of("foo[2]", Map.of("foo", List.of("bar")), null), + Arguments.of("foo", null, null), + Arguments.of("foo[0]", mapWithNull, null), + Arguments.of("foo[0]", Map.of("foo", Map.of("bar", "baz")), null)); + } +} diff --git a/client/client-rulesengine/src/test/java/software/amazon/smithy/java/client/rulesengine/EndpointRulesPluginTest.java b/client/client-rulesengine/src/test/java/software/amazon/smithy/java/client/rulesengine/EndpointRulesPluginTest.java new file mode 100644 index 000000000..1b76ae9ff --- /dev/null +++ b/client/client-rulesengine/src/test/java/software/amazon/smithy/java/client/rulesengine/EndpointRulesPluginTest.java @@ -0,0 +1,97 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.client.rulesengine; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.instanceOf; +import static org.hamcrest.Matchers.not; + +import org.junit.jupiter.api.Test; +import software.amazon.smithy.java.client.core.ClientConfig; +import software.amazon.smithy.java.client.core.ClientContext; +import software.amazon.smithy.java.client.core.endpoint.Endpoint; +import software.amazon.smithy.java.client.core.endpoint.EndpointResolver; +import software.amazon.smithy.java.core.schema.ApiService; +import software.amazon.smithy.java.core.schema.Schema; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.node.Node; +import software.amazon.smithy.model.shapes.ServiceShape; +import software.amazon.smithy.model.shapes.ShapeId; +import software.amazon.smithy.model.traits.Trait; +import software.amazon.smithy.rulesengine.language.EndpointRuleSet; +import software.amazon.smithy.utils.IoUtils; + +public class EndpointRulesPluginTest { + @Test + public void addsEndpointResolver() { + var contents = IoUtils.readUtf8Resource(getClass(), "example-complex-ruleset.json"); + var program = new RulesEngine().compile(EndpointRuleSet.fromNode(Node.parse(contents))); + var plugin = EndpointRulesPlugin.from(program); + var builder = ClientConfig.builder(); + plugin.configureClient(builder); + + assertThat(builder.endpointResolver(), instanceOf(EndpointRulesResolver.class)); + } + + @Test + public void doesNotModifyExistingResolver() { + var contents = IoUtils.readUtf8Resource(getClass(), "example-complex-ruleset.json"); + var program = new RulesEngine().compile(EndpointRuleSet.fromNode(Node.parse(contents))); + var plugin = EndpointRulesPlugin.from(program); + var builder = ClientConfig.builder().endpointResolver(EndpointResolver.staticHost("foo.com")); + plugin.configureClient(builder); + + assertThat(builder.endpointResolver(), not(instanceOf(EndpointRulesResolver.class))); + } + + @Test + public void modifiesResolverIfCustomEndpointSet() { + var contents = IoUtils.readUtf8Resource(getClass(), "example-complex-ruleset.json"); + var program = new RulesEngine().compile(EndpointRuleSet.fromNode(Node.parse(contents))); + var plugin = EndpointRulesPlugin.from(program); + var builder = ClientConfig.builder() + .endpointResolver(EndpointResolver.staticHost("foo.com")) + .putConfig(ClientContext.CUSTOM_ENDPOINT, Endpoint.builder().uri("https://example.com").build()); + plugin.configureClient(builder); + + assertThat(builder.endpointResolver(), instanceOf(EndpointRulesResolver.class)); + } + + @Test + public void onlyModifiesResolverIfProgramFound() { + var plugin = EndpointRulesPlugin.from((RulesProgram) null); + var builder = ClientConfig.builder(); + plugin.configureClient(builder); + + assertThat(builder.endpointResolver(), not(instanceOf(EndpointRulesResolver.class))); + } + + @Test + public void loadsRulesFromServiceSchemaTraits() { + var model = Model.assembler() + .addImport(getClass().getResource("minimal-ruleset.smithy")) + .discoverModels() + .assemble() + .unwrap(); + + // Create a service schema. + var service = model.expectShape(ShapeId.from("example#FizzBuzz"), ServiceShape.class); + var traits = new Trait[service.getAllTraits().size()]; + int i = 0; + for (var t : service.getAllTraits().values()) { + traits[i++] = t; + } + var schema = Schema.createService(service.getId(), traits); + ApiService api = () -> schema; + + // Create the plugin from the service schema. + var plugin = EndpointRulesPlugin.create(); + var builder = ClientConfig.builder().service(api); + builder.applyPlugin(plugin); + + assertThat(builder.endpointResolver(), instanceOf(EndpointRulesResolver.class)); + } +} diff --git a/client/client-rulesengine/src/test/java/software/amazon/smithy/java/client/rulesengine/EndpointRulesResolverTest.java b/client/client-rulesengine/src/test/java/software/amazon/smithy/java/client/rulesengine/EndpointRulesResolverTest.java new file mode 100644 index 000000000..42c8277b3 --- /dev/null +++ b/client/client-rulesengine/src/test/java/software/amazon/smithy/java/client/rulesengine/EndpointRulesResolverTest.java @@ -0,0 +1,184 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.client.rulesengine; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.instanceOf; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletionException; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.java.client.core.endpoint.EndpointResolverParams; +import software.amazon.smithy.java.core.schema.ApiOperation; +import software.amazon.smithy.java.core.schema.ApiService; +import software.amazon.smithy.java.core.schema.Schema; +import software.amazon.smithy.java.core.schema.SerializableStruct; +import software.amazon.smithy.java.core.schema.ShapeBuilder; +import software.amazon.smithy.java.core.serde.ShapeSerializer; +import software.amazon.smithy.java.core.serde.TypeRegistry; +import software.amazon.smithy.model.node.Node; +import software.amazon.smithy.model.shapes.ShapeId; +import software.amazon.smithy.model.traits.Trait; +import software.amazon.smithy.rulesengine.traits.StaticContextParamDefinition; +import software.amazon.smithy.rulesengine.traits.StaticContextParamsTrait; + +public class EndpointRulesResolverTest { + @Test + public void resolvesEndpointWithoutStaticParams() { + EndpointResolverParams params = EndpointResolverParams.builder() + .inputValue(getInput()) + .operation(getOperation(Map.of())) + .build(); + + var program = new RulesEngine().precompiledBuilder() + .bytecode( + RulesProgram.VERSION, + (byte) 0, + (byte) 0, + RulesProgram.LOAD_CONST, + (byte) 0, + RulesProgram.RETURN_ENDPOINT, + (byte) 0) + .constantPool("https://example.com") + .build(); + var resolver = new EndpointRulesResolver(program); + var result = resolver.resolveEndpoint(params).join(); + + assertThat(result.uri().toString(), equalTo("https://example.com")); + } + + private SerializableStruct getInput() { + return new SerializableStruct() { + @Override + public Schema schema() { + return null; + } + + @Override + public void serializeMembers(ShapeSerializer serializer) {} + + @Override + public T getMemberValue(Schema member) { + return null; + } + }; + } + + private ApiOperation getOperation( + Map staticParams + ) { + return new ApiOperation<>() { + @Override + public ShapeBuilder inputBuilder() { + return null; + } + + @Override + public ShapeBuilder outputBuilder() { + return null; + } + + @Override + public Schema schema() { + Trait[] traits = null; + if (staticParams != null) { + traits = new Trait[] { + StaticContextParamsTrait + .builder() + .parameters(staticParams) + .build() + }; + } else { + traits = new Trait[0]; + } + return Schema.createOperation(ShapeId.from("smithy.example#Foo"), traits); + } + + @Override + public Schema inputSchema() { + return Schema.structureBuilder(ShapeId.from("smithy.example#FooInput")).build(); + } + + @Override + public Schema outputSchema() { + return Schema.structureBuilder(ShapeId.from("smithy.example#FooOutput")).build(); + } + + @Override + public TypeRegistry errorRegistry() { + return null; + } + + @Override + public List effectiveAuthSchemes() { + return List.of(); + } + + @Override + public ApiService service() { + return null; + } + }; + } + + @Test + public void resolvesEndpointWithStaticParams() { + var op = getOperation(Map.of("foo", + StaticContextParamDefinition.builder() + .value(Node.from("https://foo.com")) + .build())); + EndpointResolverParams params = EndpointResolverParams.builder() + .inputValue(getInput()) + .operation(op) + .build(); + + var program = new RulesEngine().precompiledBuilder() + .bytecode( + RulesProgram.VERSION, + (byte) 1, + (byte) 0, + RulesProgram.LOAD_REGISTER, // load foo + (byte) 0, + RulesProgram.RETURN_ENDPOINT, + (byte) 0) + .constantPool("https://example.com") + .parameters(new ParamDefinition("foo", false, null, null)) + .build(); + var resolver = new EndpointRulesResolver(program); + var result = resolver.resolveEndpoint(params).join(); + + assertThat(result.uri().toString(), equalTo("https://foo.com")); + } + + @Test + public void returnsCfInsteadOfThrowingOnError() { + EndpointResolverParams params = EndpointResolverParams.builder() + .inputValue(getInput()) + .operation(getOperation(Map.of())) + .build(); + + var program = new RulesEngine().precompiledBuilder() + .bytecode( + RulesProgram.VERSION, + (byte) 1, + (byte) 0) + .constantPool("https://example.com") + .parameters(new ParamDefinition("foo", false, null, null)) + .build(); + var resolver = new EndpointRulesResolver(program); + var result = resolver.resolveEndpoint(params); + + try { + result.join(); + Assertions.fail("Expected to throw"); + } catch (CompletionException e) { + assertThat(e.getCause(), instanceOf(RulesEvaluationError.class)); + } + } +} diff --git a/client/client-rulesengine/src/test/java/software/amazon/smithy/java/client/rulesengine/EndpointUtilsTest.java b/client/client-rulesengine/src/test/java/software/amazon/smithy/java/client/rulesengine/EndpointUtilsTest.java new file mode 100644 index 000000000..e392f61f1 --- /dev/null +++ b/client/client-rulesengine/src/test/java/software/amazon/smithy/java/client/rulesengine/EndpointUtilsTest.java @@ -0,0 +1,157 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.client.rulesengine; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; + +import java.net.URI; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.MethodSource; +import software.amazon.smithy.model.node.Node; +import software.amazon.smithy.rulesengine.language.evaluation.value.EndpointValue; +import software.amazon.smithy.rulesengine.language.evaluation.value.Value; +import software.amazon.smithy.rulesengine.language.syntax.Identifier; +import software.amazon.smithy.rulesengine.language.syntax.expressions.Template; +import software.amazon.smithy.utils.Pair; + +public class EndpointUtilsTest { + @ParameterizedTest + @MethodSource("verifyObjectProvider") + public void verifiesObjects(Object value, boolean isValid) { + try { + EndpointUtils.verifyObject(value); + if (!isValid) { + Assertions.fail("Expected " + value + " to fail"); + } + } catch (UnsupportedOperationException e) { + if (isValid) { + throw e; + } + } + } + + public static List verifyObjectProvider() throws Exception { + return List.of( + Arguments.of("hi", true), + Arguments.of(1, true), + Arguments.of(true, true), + Arguments.of(false, true), + Arguments.of(StringTemplate.from(Template.fromString("https://foo.com")), true), + Arguments.of(new URI("/"), true), + Arguments.of(List.of(true, 1), true), + Arguments.of(Map.of("hi", List.of(true, List.of("a"))), true), + // Invalid + Arguments.of(Pair.of("a", "b"), false), + Arguments.of(List.of(Pair.of("a", "b")), false), + Arguments.of(Map.of(1, 1), false), + Arguments.of(Map.of("a", Pair.of("a", "b")), false)); + } + + @ParameterizedTest + @CsvSource({ + // Test cases for valid IP addresses + "'http://192.168.1.1/index.html', true", + "'https://192.168.1.1:8080/path', true", + "'http://127.0.0.1/', true", + "'https://255.255.255.255/', true", + "'http://0.0.0.0/', true", + "'https://1.2.3.4:8443/path?query=value', true", + "'http://10.0.0.1', true", + "'https://[2001:db8:85a3:8d3:1319:8a2e:370:7348]/', true", + "'http://[::1]/', true", + "'https://[fe80::1ff:fe23:4567:890a]:8443/', true", + "'https://[2001:db8::1]', true", + // Test cases for non-IP hostnames + "'http://example.com/', false", + "'https://www.google.com/search?q=test', false", + "'http://subdomain.example.org:8080/path', false", + "'https://localhost/test', false", + // Test cases for invalid IP formats + "'http://192.168.1/incomplete', false", + "'https://256.1.1.1/invalid', false", + "'http://1.2.3.4.5/toomanyparts', false", + "'https://192.168.1.a/invalid', false", + "'http://a.b.c.d/notdigits', false", + "'https://192.168.1.256/outofrange', false", + // Additional domain test cases + "'https://domain-with-hyphens.com/', false", + "'http://underscore_domain.org/', false", + "'https://a.very.long.domain.name.example.com/path', false" + }) + void testGetUriIsIp(String uriString, boolean expected) throws Exception { + URI uri = new URI(uriString); + boolean actual = (boolean) EndpointUtils.getUriProperty(uri, "isIp"); + + assertThat(expected, equalTo(actual)); + } + + @ParameterizedTest + @MethodSource("testConvertsValuesToObjectsProvider") + void testConvertsValuesToObjects(Value value, Object object) { + var converted = EndpointUtils.convertInputParamValue(value); + + assertThat(converted, equalTo(object)); + } + + public static List testConvertsValuesToObjectsProvider() { + return List.of( + Arguments.of(Value.emptyValue(), null), + Arguments.of(Value.stringValue("hi"), "hi"), + Arguments.of(Value.booleanValue(true), true), + Arguments.of(Value.booleanValue(false), false), + Arguments.of(Value.integerValue(1), 1), + Arguments.of(Value.arrayValue(List.of(Value.integerValue(1))), List.of(1)), + Arguments.of(Value.recordValue(Map.of(Identifier.of("hi"), Value.integerValue(1))), + Map.of("hi", 1)) + + ); + } + + @Test + public void throwsWhenValueUnsupported() { + Assertions.assertThrows(RulesEvaluationError.class, + () -> EndpointUtils.convertInputParamValue(EndpointValue.builder().url("https://foo").build())); + } + + @Test + public void getsUriParts() throws Exception { + var uri = new URI("http://localhost/foo/bar"); + + assertThat(EndpointUtils.getUriProperty(uri, "authority"), equalTo(uri.getAuthority())); + assertThat(EndpointUtils.getUriProperty(uri, "scheme"), equalTo(uri.getScheme())); + assertThat(EndpointUtils.getUriProperty(uri, "path"), equalTo("/foo/bar")); + assertThat(EndpointUtils.getUriProperty(uri, "normalizedPath"), equalTo("/foo/bar/")); + } + + @ParameterizedTest + @MethodSource("convertsNodeInputsProvider") + void convertsNodeInputs(Node value, Object object) { + var converted = EndpointUtils.convertNode(value); + + assertThat(converted, equalTo(object)); + } + + public static List convertsNodeInputsProvider() { + return List.of( + Arguments.of(Node.from("hi"), "hi"), + Arguments.of(Node.from("hi"), "hi"), + Arguments.of(Node.from(true), true), + Arguments.of(Node.from(false), false), + Arguments.of(Node.fromNodes(Node.from("aa")), List.of("aa"))); + } + + @Test + public void throwsOnUnsupportNodeInput() { + Assertions.assertThrows(RulesEvaluationError.class, () -> EndpointUtils.convertNode(Node.from(1))); + } +} diff --git a/client/client-rulesengine/src/test/java/software/amazon/smithy/java/client/rulesengine/RulesCompilerTest.java b/client/client-rulesengine/src/test/java/software/amazon/smithy/java/client/rulesengine/RulesCompilerTest.java new file mode 100644 index 000000000..536000c19 --- /dev/null +++ b/client/client-rulesengine/src/test/java/software/amazon/smithy/java/client/rulesengine/RulesCompilerTest.java @@ -0,0 +1,157 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.client.rulesengine; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.nullValue; + +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import software.amazon.smithy.java.aws.client.awsjson.AwsJson1Protocol; +import software.amazon.smithy.java.client.core.CallContext; +import software.amazon.smithy.java.client.core.RequestOverrideConfig; +import software.amazon.smithy.java.client.core.auth.scheme.AuthSchemeResolver; +import software.amazon.smithy.java.client.core.endpoint.Endpoint; +import software.amazon.smithy.java.client.core.endpoint.EndpointContext; +import software.amazon.smithy.java.client.core.interceptors.ClientInterceptor; +import software.amazon.smithy.java.client.core.interceptors.RequestHook; +import software.amazon.smithy.java.context.Context; +import software.amazon.smithy.java.core.serde.document.Document; +import software.amazon.smithy.java.dynamicclient.DynamicClient; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.shapes.ServiceShape; +import software.amazon.smithy.model.shapes.ShapeId; +import software.amazon.smithy.rulesengine.traits.EndpointRuleSetTrait; +import software.amazon.smithy.rulesengine.traits.EndpointTestCase; +import software.amazon.smithy.rulesengine.traits.EndpointTestsTrait; + +public class RulesCompilerTest { + @ParameterizedTest + @MethodSource("testCaseProvider") + public void testRunner(Path modelFile) { + var model = Model.assembler() + .discoverModels() + .addImport(modelFile) + .assemble() + .unwrap(); + var service = model.expectShape(ShapeId.from("example#FizzBuzz"), ServiceShape.class); + var engine = new RulesEngine(); + var program = engine.compile(service.expectTrait(EndpointRuleSetTrait.class).getEndpointRuleSet()); + var plugin = EndpointRulesPlugin.from(program); + var testCases = service.expectTrait(EndpointTestsTrait.class); + + var client = DynamicClient.builder() + .model(model) + .service(service.getId()) + .protocol(new AwsJson1Protocol(service.getId())) + .authSchemeResolver(AuthSchemeResolver.NO_AUTH) + .addPlugin(plugin) + .build(); + + for (var test : testCases.getTestCases()) { + var testParams = test.getParams(); + var ctx = Context.create(); + Map input = new HashMap<>(); + for (var entry : testParams.getStringMap().entrySet()) { + input.put(entry.getKey(), EndpointUtils.convertNode(entry.getValue())); + } + var expected = test.getExpect(); + expected.getEndpoint().ifPresent(expectedEndpoint -> { + try { + var result = resolveEndpoint(test, client, plugin, ctx, input); + assertThat(result.uri().toString(), equalTo(expectedEndpoint.getUrl())); + var actualHeaders = result.property(EndpointContext.HEADERS); + if (expectedEndpoint.getHeaders().isEmpty()) { + assertThat(actualHeaders, nullValue()); + } else { + assertThat(actualHeaders, equalTo(expectedEndpoint.getHeaders())); + } + // TODO: validate properties too. + } catch (RulesEvaluationError e) { + Assertions.fail("Expected ruleset to succeed: " + + modelFile + " : " + + test.getDocumentation() + + " : " + e, e); + } + }); + expected.getError().ifPresent(expectedError -> { + try { + var result = resolveEndpoint(test, client, plugin, ctx, input); + Assertions.fail("Expected ruleset to fail: " + modelFile + " : " + test.getDocumentation() + + ", but resolved " + result); + } catch (RulesEvaluationError e) { + // pass + } + }); + } + } + + private Endpoint resolveEndpoint( + EndpointTestCase test, + DynamicClient client, + EndpointRulesPlugin plugin, + Context ctx, + Map input + ) { + // Supports a single operations inputs + if (test.getOperationInputs().isEmpty()) { + return plugin.getProgram().resolveEndpoint(ctx, input); + } + + // The rules have operation input params, so simulate sending an operation. + var inputs = test.getOperationInputs().get(0); + var name = inputs.getOperationName(); + var inputParams = EndpointUtils.convertNode(inputs.getOperationParams(), true); + var resolvedEndpoint = new Endpoint[1]; + var override = RequestOverrideConfig.builder() + .addInterceptor(new ClientInterceptor() { + @Override + public void readBeforeTransmit(RequestHook hook) { + resolvedEndpoint[0] = hook.context().get(CallContext.ENDPOINT); + throw new RulesEvaluationError("foo"); + } + }); + + if (!inputs.getBuiltInParams().isEmpty()) { + inputs.getBuiltInParams().getStringMember("SDK::Endpoint").ifPresent(value -> { + override.putConfig(CallContext.ENDPOINT, Endpoint.builder().uri(value.getValue()).build()); + }); + } + + try { + var document = Document.ofObject(inputParams); + client.call(name, document, override.build()); + throw new RuntimeException("Expected exception"); + } catch (RulesEvaluationError e) { + if (e.getMessage().equals("foo")) { + return resolvedEndpoint[0]; + } else { + throw e; + } + } + } + + public static List testCaseProvider() throws Exception { + List result = new ArrayList<>(); + var baseUri = RulesCompilerTest.class.getResource("runner").toURI(); + var basePath = Paths.get(baseUri); + for (var file : Objects.requireNonNull(basePath.toFile().listFiles())) { + if (!file.isDirectory()) { + result.add(file.toPath()); + } + } + return result; + } +} diff --git a/client/client-rulesengine/src/test/java/software/amazon/smithy/java/client/rulesengine/RulesEngineTest.java b/client/client-rulesengine/src/test/java/software/amazon/smithy/java/client/rulesengine/RulesEngineTest.java new file mode 100644 index 000000000..035ebf288 --- /dev/null +++ b/client/client-rulesengine/src/test/java/software/amazon/smithy/java/client/rulesengine/RulesEngineTest.java @@ -0,0 +1,149 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.client.rulesengine; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; + +import java.nio.ByteBuffer; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.java.context.Context; + +public class RulesEngineTest { + @Test + public void canProvideFunctionsWhenLoadingRules() { + var helloReturnValue = "hi!"; + var engine = new RulesEngine(); + + engine.addExtension(new RulesExtension() { + @Override + public List getFunctions() { + return List.of( + new RulesFunction() { + @Override + public int getOperandCount() { + return 1; + } + + @Override + public String getFunctionName() { + return "hello"; + } + + @Override + public Object apply1(Object value) { + return value; + } + }); + } + }); + + var bytecode = new byte[] { + RulesProgram.VERSION, + 0, // params + 0, // registers + RulesProgram.LOAD_CONST, + 0, + RulesProgram.FN, + 0, + RulesProgram.RETURN_ERROR + }; + + var program = engine.precompiledBuilder() + .bytecode(ByteBuffer.wrap(bytecode)) + .constantPool(helloReturnValue) + .functionNames("hello") + .build(); + + var e = Assertions.assertThrows(RulesEvaluationError.class, + () -> program.resolveEndpoint(Context.create(), Map.of())); + + assertThat(e.getMessage(), containsString(helloReturnValue)); + } + + @Test + public void failsEarlyWhenFunctionIsMissing() { + var helloReturnValue = "hi!"; + var engine = new RulesEngine(); + var bytecode = new byte[] { + RulesProgram.VERSION, + 0, // params + 0, // registers + RulesProgram.LOAD_CONST, + 0, + RulesProgram.FN, + 0, + RulesProgram.RETURN_ERROR + }; + + Assertions.assertThrows(UnsupportedOperationException.class, + () -> engine.precompiledBuilder() + .bytecode(bytecode) + .constantPool(helloReturnValue) + .functionNames("hello") + .build()); + } + + @Test + public void failsEarlyWhenTooManyRegisters() { + var engine = new RulesEngine(); + var params = new ParamDefinition[257]; + for (var i = 0; i < 257; i++) { + params[i] = new ParamDefinition("r" + i); + } + + Assertions.assertThrows(IllegalArgumentException.class, + () -> engine.precompiledBuilder() + .bytecode(RulesProgram.VERSION, (byte) 255, (byte) 0) + .parameters(params) + .build()); + } + + @Test + public void callsCustomBuiltins() { + var helloReturnValue = "hi!"; + var engine = new RulesEngine(); + var constantPool = new Object[] {helloReturnValue}; + + // Add a built-in provider that just gets ignored. + engine.addBuiltinProvider((name, ctx) -> null); + + engine.addBuiltinProvider((name, ctx) -> { + if (name.equals("customTest")) { + return helloReturnValue; + } + return null; + }); + + var bytecode = new byte[] { + RulesProgram.VERSION, + 2, // params + 0, // registers + RulesProgram.LOAD_REGISTER, + 0, + RulesProgram.RETURN_ERROR + }; + + var program = engine.precompiledBuilder() + .bytecode(bytecode) + .constantPool(constantPool) + .parameters( + new ParamDefinition("foo", false, null, "customTest"), + // This register will try to fill in a default from a builtin named "unknown", but one doesn't + // exist so it is initialized to null. It's not required, so this is allowed to be null. It's + // like if a built-in is unable to optionally find your AWS::Auth::AccountId ID. + new ParamDefinition("bar", false, null, "unknown")) + .build(); + + var e = Assertions.assertThrows(RulesEvaluationError.class, + () -> program.resolveEndpoint(Context.create(), Map.of())); + + assertThat(e.getMessage(), containsString(helloReturnValue)); + } +} diff --git a/client/client-rulesengine/src/test/java/software/amazon/smithy/java/client/rulesengine/RulesProgramTest.java b/client/client-rulesengine/src/test/java/software/amazon/smithy/java/client/rulesengine/RulesProgramTest.java new file mode 100644 index 000000000..f223423bf --- /dev/null +++ b/client/client-rulesengine/src/test/java/software/amazon/smithy/java/client/rulesengine/RulesProgramTest.java @@ -0,0 +1,106 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.client.rulesengine; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.is; + +import java.util.Map; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.java.context.Context; + +public class RulesProgramTest { + @Test + public void failsWhenMissingVersion() { + var engine = new RulesEngine(); + Assertions.assertThrows( + IllegalArgumentException.class, + () -> engine.precompiledBuilder() + .bytecode(RulesProgram.RETURN_ERROR, (byte) 0, (byte) 0) + .build()); + } + + @Test + public void failsWhenVersionIsTooBig() { + var engine = new RulesEngine(); + + Assertions.assertThrows( + IllegalArgumentException.class, + () -> engine.precompiledBuilder() + .bytecode((byte) -127, (byte) 0, (byte) 0) + .build()); + } + + @Test + public void failsWhenNotEnoughBytes() { + var engine = new RulesEngine(); + + Assertions.assertThrows( + IllegalArgumentException.class, + () -> engine.precompiledBuilder().bytecode((byte) -1).build()); + } + + @Test + public void failsWhenMissingParams() { + var engine = new RulesEngine(); + + Assertions.assertThrows( + IllegalArgumentException.class, + () -> engine.precompiledBuilder() + .bytecode(RulesProgram.VERSION, (byte) 1, (byte) 0) + .build()); + } + + @Test + public void convertsProgramsToStrings() { + var program = getErrorProgram(); + var str = program.toString(); + + assertThat(str, containsString("Constants:")); + assertThat(str, containsString("Registers:")); + assertThat(str, containsString("Instructions:")); + assertThat(str, containsString("0: String: Error!")); + assertThat(str, containsString("0: ParamDefinition[name=a")); + assertThat(str, containsString("003: LOAD_CONST")); + assertThat(str, containsString("005: RETURN_ERROR")); + } + + private RulesProgram getErrorProgram() { + var engine = new RulesEngine(); + + return engine.precompiledBuilder() + .bytecode( + RulesProgram.VERSION, + (byte) 1, // params + (byte) 0, // registers + RulesProgram.LOAD_CONST, + (byte) 0, + RulesProgram.RETURN_ERROR) + .constantPool("Error!") + .parameters(new ParamDefinition("a")) + .build(); + } + + @Test + public void exposesConstantsAndRegisters() { + var program = getErrorProgram(); + + assertThat(program.getConstantPool().length, is(1)); + assertThat(program.getParamDefinitions().size(), is(1)); + } + + @Test + public void runsPrograms() { + var program = getErrorProgram(); + + var e = Assertions.assertThrows(RulesEvaluationError.class, + () -> program.resolveEndpoint(Context.create(), Map.of("a", "foo"))); + + assertThat(e.getMessage(), containsString("Error!")); + } +} diff --git a/client/client-rulesengine/src/test/java/software/amazon/smithy/java/client/rulesengine/RulesVmTest.java b/client/client-rulesengine/src/test/java/software/amazon/smithy/java/client/rulesengine/RulesVmTest.java new file mode 100644 index 000000000..e798315aa --- /dev/null +++ b/client/client-rulesengine/src/test/java/software/amazon/smithy/java/client/rulesengine/RulesVmTest.java @@ -0,0 +1,457 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.client.rulesengine; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; + +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.java.client.core.endpoint.EndpointContext; +import software.amazon.smithy.java.context.Context; +import software.amazon.smithy.rulesengine.language.syntax.expressions.Template; + +public class RulesVmTest { + @Test + public void throwsWhenUnableToResolveEndpoint() { + var engine = new RulesEngine(); + var program = engine.precompiledBuilder() + .bytecode(RulesProgram.VERSION, (byte) 0, (byte) 0) + .constantPool(1) + .build(); + var e = Assertions.assertThrows(RulesEvaluationError.class, + () -> program.resolveEndpoint(Context.create(), Map.of())); + + assertThat(e.getMessage(), containsString("No value returned from rules engine")); + } + + @Test + public void throwsForInvalidOpcode() { + var engine = new RulesEngine(); + var bytecode = new byte[] {RulesProgram.VERSION, (byte) 0, (byte) 0, 120}; + var program = engine.precompiledBuilder() + .bytecode(bytecode) + .constantPool(1) + .build(); + var e = Assertions.assertThrows(RulesEvaluationError.class, + () -> program.resolveEndpoint(Context.create(), Map.of())); + + assertThat(e.getMessage(), containsString("Unknown rules engine instruction: 120")); + } + + @Test + public void throwsWithContextWhenTypeIsInvalid() { + var engine = new RulesEngine(); + var bytecode = new byte[] { + RulesProgram.VERSION, + (byte) 0, // params + (byte) 0, // registers + RulesProgram.LOAD_CONST, + 0, + RulesProgram.RESOLVE_TEMPLATE, + 0, // Refers to invalid type. Expects string, given integer. + 0 + }; + var program = engine.precompiledBuilder() + .bytecode(bytecode) + .constantPool(1) + .build(); + var e = Assertions.assertThrows(RulesEvaluationError.class, + () -> program.resolveEndpoint(Context.create(), Map.of())); + + assertThat(e.getMessage(), containsString("Unexpected value type")); + assertThat(e.getMessage(), containsString("at address 5")); + } + + @Test + public void throwsWithContextWhenBytecodeIsMalformed() { + var engine = new RulesEngine(); + var bytecode = new byte[] { + RulesProgram.VERSION, + (byte) 0, // params + (byte) 0, // registers + RulesProgram.LOAD_CONST, + 0, + RulesProgram.RESOLVE_TEMPLATE // missing following byte + }; + + var program = engine.precompiledBuilder() + .bytecode(bytecode) + .constantPool(1) + .build(); + var e = Assertions.assertThrows(RulesEvaluationError.class, + () -> program.resolveEndpoint(Context.create(), Map.of())); + + assertThat(e.getMessage(), containsString("Malformed bytecode encountered while evaluating rules engine")); + } + + @Test + public void failsIfRequiredRegisterMissing() { + var bytecode = new byte[] { + RulesProgram.VERSION, + (byte) 1, // params + (byte) 0, // registers + }; + var program = new RulesEngine().precompiledBuilder() + .bytecode(bytecode) + .constantPool(1) + .parameters(new ParamDefinition("foo", true, null, null)) + .build(); + var e = Assertions.assertThrows(RulesEvaluationError.class, + () -> program.resolveEndpoint(Context.create(), Map.of())); + + assertThat(e.getMessage(), containsString("Required rules engine parameter missing: foo")); + } + + @Test + public void setsDefaultRegisterValues() { + var engine = new RulesEngine(); + var bytecode = new byte[] { + RulesProgram.VERSION, + (byte) 1, // params + (byte) 0, // registers + RulesProgram.LOAD_REGISTER, + 0, + RulesProgram.RETURN_ENDPOINT, + 0 + }; + var program = engine.precompiledBuilder() + .bytecode(bytecode) + .constantPool(1) + .parameters(new ParamDefinition("foo", true, "https://foo.com", null)) + .build(); + var endpoint = program.resolveEndpoint(Context.create(), Map.of()); + + assertThat(endpoint.toString(), containsString("https://foo.com")); + } + + @Test + public void resizesTheStackWhenNeeded() { + var engine = new RulesEngine(); + var bytecode = new byte[] { + RulesProgram.VERSION, + (byte) 1, // params + (byte) 0, // registers + RulesProgram.LOAD_REGISTER, + 0, + RulesProgram.LOAD_REGISTER, + 0, + RulesProgram.LOAD_REGISTER, + 0, + RulesProgram.LOAD_REGISTER, + 0, + RulesProgram.LOAD_REGISTER, + 0, + RulesProgram.LOAD_REGISTER, + 0, + RulesProgram.LOAD_REGISTER, + 0, + RulesProgram.LOAD_REGISTER, + 0, + RulesProgram.LOAD_REGISTER, + 0, + RulesProgram.LOAD_REGISTER, + 0, + RulesProgram.RETURN_ENDPOINT, + 0 + }; + var program = engine.precompiledBuilder() + .bytecode(bytecode) + .constantPool(1) + .parameters(new ParamDefinition("foo", false, null, null)) + .build(); + var endpoint = program.resolveEndpoint(Context.create(), Map.of("foo", "https://foo.com")); + + assertThat(endpoint.toString(), containsString("https://foo.com")); + } + + @Test + public void resolvesTemplates() { + var engine = new RulesEngine(); + var bytecode = new byte[] { + RulesProgram.VERSION, + (byte) 1, // params + (byte) 0, // registers + RulesProgram.LOAD_REGISTER, // 1 byte register + 0, + RulesProgram.RESOLVE_TEMPLATE, // 2 byte constant + 0, + 0, + RulesProgram.RETURN_ENDPOINT, // 1 byte, no headers or properties + 0 + }; + var program = engine.precompiledBuilder() + .bytecode(bytecode) + .constantPool(StringTemplate.from(Template.fromString("https://{foo}.bar"))) + .parameters(new ParamDefinition("foo", false, "hi", null)) + .build(); + var endpoint = program.resolveEndpoint(Context.create(), Map.of()); + + assertThat(endpoint.toString(), containsString("https://hi.bar")); + } + + @Test + public void resolvesNoExpressionTemplates() { + var engine = new RulesEngine(); + var bytecode = new byte[] { + RulesProgram.VERSION, + (byte) 0, // params + (byte) 0, // registers + RulesProgram.RESOLVE_TEMPLATE, // 2 byte constant + 0, + 0, + RulesProgram.RETURN_ENDPOINT, // 1 byte, no headers or properties + 0 + }; + var program = engine.precompiledBuilder() + .bytecode(bytecode) + .constantPool(StringTemplate.from(Template.fromString("https://hi.bar"))) + .build(); + var endpoint = program.resolveEndpoint(Context.create(), Map.of()); + + assertThat(endpoint.toString(), containsString("https://hi.bar")); + } + + @Test + public void wrapsInvalidURIs() { + var engine = new RulesEngine(); + var bytecode = new byte[] { + RulesProgram.VERSION, + (byte) 0, // params + (byte) 0, // registers + RulesProgram.RESOLVE_TEMPLATE, // 2 byte constant + 0, + 0, + RulesProgram.RETURN_ENDPOINT, // 1 byte, no headers or properties + 0 + }; + var program = engine.precompiledBuilder() + .bytecode(bytecode) + .constantPool(StringTemplate.from(Template.fromString("!??!!\\"))) + .build(); + var e = Assertions.assertThrows(RulesEvaluationError.class, + () -> program.resolveEndpoint(Context.create(), Map.of())); + + assertThat(e.getMessage(), containsString("Error creating URI")); + } + + @Test + public void createsMapForEndpointHeaders() { + var engine = new RulesEngine(); + var bytecode = new byte[] { + RulesProgram.VERSION, + (byte) 0, // params + (byte) 0, // registers + RulesProgram.LOAD_CONST, // push list value 0, "def" + 2, + RulesProgram.CREATE_LIST, // push list with one value, ["def"]. + 1, + RulesProgram.LOAD_CONST, // push map key "abc" + 1, + RulesProgram.CREATE_MAP, // push with one KVP: {"abc": ["def"]} (the endpoint headers) + 1, + RulesProgram.RESOLVE_TEMPLATE, // push resolved string template at constant 0 (2 byte constant) + 0, + 0, + RulesProgram.RETURN_ENDPOINT, // Return an endpoint that does have headers. + 1 + }; + var program = engine.precompiledBuilder() + .bytecode(bytecode) + .constantPool(StringTemplate.from(Template.fromString("https://hi.bar")), "abc", "def") + .build(); + var endpoint = program.resolveEndpoint(Context.create(), Map.of()); + + assertThat(endpoint.toString(), containsString("https://hi.bar")); + assertThat(endpoint.property(EndpointContext.HEADERS), equalTo(Map.of("abc", List.of("def")))); + } + + @Test + public void testsIfRegisterSet() { + var engine = new RulesEngine(); + var bytecode = new byte[] { + RulesProgram.VERSION, + (byte) 1, // params + (byte) 0, // registers + RulesProgram.TEST_REGISTER_ISSET, + 0, + RulesProgram.RETURN_VALUE + }; + var program = engine.precompiledBuilder() + .bytecode(bytecode) + .parameters(new ParamDefinition("hi", false, "abc", null)) + .build(); + var result = program.run(Context.create(), Map.of()); + + assertThat(result, equalTo(true)); + } + + @Test + public void testsIfValueRegisterSet() { + var engine = new RulesEngine(); + var bytecode = new byte[] { + RulesProgram.VERSION, + (byte) 1, // params + (byte) 0, // registers + RulesProgram.LOAD_REGISTER, + 0, + RulesProgram.ISSET, + RulesProgram.RETURN_VALUE + }; + var program = engine.precompiledBuilder() + .bytecode(bytecode) + .parameters(new ParamDefinition("hi", false, "abc", null)) + .build(); + + var result = program.run(Context.create(), Map.of()); + + assertThat(result, equalTo(true)); + } + + @Test + public void testNotOpcode() { + var engine = new RulesEngine(); + var bytecode = new byte[] { + RulesProgram.VERSION, + (byte) 1, // params + (byte) 0, // registers + RulesProgram.LOAD_REGISTER, + 0, + RulesProgram.NOT, + RulesProgram.RETURN_VALUE + }; + var program = engine.precompiledBuilder() + .bytecode(bytecode) + .parameters(new ParamDefinition("hi", false, false, null)) + .build(); + var result = program.run(Context.create(), Map.of()); + + assertThat(result, equalTo(true)); + } + + @Test + public void testTrueOpcodes() { + var engine = new RulesEngine(); + var bytecode = new byte[] { + RulesProgram.VERSION, + (byte) 3, // params + (byte) 0, // registers + RulesProgram.LOAD_REGISTER, + 0, + RulesProgram.IS_TRUE, + RulesProgram.LOAD_REGISTER, + 1, + RulesProgram.IS_TRUE, + RulesProgram.LOAD_REGISTER, + 2, + RulesProgram.IS_TRUE, + RulesProgram.TEST_REGISTER_IS_TRUE, + 0, + RulesProgram.TEST_REGISTER_IS_TRUE, + 1, + RulesProgram.TEST_REGISTER_IS_TRUE, + 2, + RulesProgram.CREATE_LIST, + 6, + RulesProgram.RETURN_VALUE}; + var program = engine.precompiledBuilder() + .bytecode(bytecode) + .parameters( + new ParamDefinition("a", false, false, null), + new ParamDefinition("b", false, true, null), + new ParamDefinition("c", false, "foo", null)) + .build(); + var result = program.run(Context.create(), Map.of()); + + assertThat(result, equalTo(List.of(false, true, false, false, true, false))); + } + + @Test + public void callsFunctions() { + var engine = new RulesEngine(); + engine.addFunction(new RulesFunction() { + @Override + public int getOperandCount() { + return 0; + } + + @Override + public String getFunctionName() { + return "gimme"; + } + + @Override + public Object apply0() { + return "gimme"; + } + }); + + var bytecode = new byte[] { + RulesProgram.VERSION, + (byte) 1, // params + (byte) 0, // registers + RulesProgram.LOAD_REGISTER, + 0, + RulesProgram.LOAD_REGISTER, + 0, + RulesProgram.FN, + 0, // "hi there" == "hi there" : true + RulesProgram.LOAD_REGISTER, + 0, + RulesProgram.FN, + 1, // uriEncode "hi there" : "hi%20there" + RulesProgram.LOAD_REGISTER, + 0, + RulesProgram.LOAD_CONST, + 0, + RulesProgram.LOAD_CONST, + 1, + RulesProgram.LOAD_CONST, + 2, + RulesProgram.FN, + 2, // "hi_there" -> "there" + RulesProgram.FN, + 3, // call gimme() + RulesProgram.CREATE_LIST, + 4, // ["gimme", "there", "hi%20there", true] + RulesProgram.RETURN_VALUE}; + var program = engine.precompiledBuilder() + .bytecode(bytecode) + .constantPool(3, 8, false) + .parameters(new ParamDefinition("a", false, "hi there", null)) + .functionNames("stringEquals", "uriEncode", "substring", "gimme") + .build(); + var result = program.run(Context.create(), Map.of()); + + assertThat(result, equalTo(List.of(true, "hi%20there", "there", "gimme"))); + } + + @Test + public void appliesGetAttrOpcode() { + var engine = new RulesEngine(); + var bytecode = new byte[] { + RulesProgram.VERSION, + (byte) 1, // params + (byte) 0, // registers + RulesProgram.LOAD_REGISTER, + 0, + RulesProgram.GET_ATTR, + 0, + 0, + RulesProgram.RETURN_VALUE}; + var program = engine.precompiledBuilder() + .bytecode(bytecode) + .constantPool(AttrExpression.parse("foo")) + .parameters(new ParamDefinition("a", false, null, null)) + .build(); + var result = program.run(Context.create(), Map.of("a", Map.of("foo", "hi"))); + + assertThat(result, equalTo("hi")); + } +} diff --git a/client/client-rulesengine/src/test/java/software/amazon/smithy/java/client/rulesengine/StdlibTest.java b/client/client-rulesengine/src/test/java/software/amazon/smithy/java/client/rulesengine/StdlibTest.java new file mode 100644 index 000000000..c394adf54 --- /dev/null +++ b/client/client-rulesengine/src/test/java/software/amazon/smithy/java/client/rulesengine/StdlibTest.java @@ -0,0 +1,102 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.client.rulesengine; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.instanceOf; +import static org.hamcrest.Matchers.is; + +import java.net.URI; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import software.amazon.smithy.java.client.core.ClientContext; +import software.amazon.smithy.java.client.core.endpoint.Endpoint; +import software.amazon.smithy.java.context.Context; + +public class StdlibTest { + @Test + public void comparesStrings() { + assertThat(Stdlib.STRING_EQUALS.apply2("a", "a"), is(true)); + assertThat(Stdlib.STRING_EQUALS.apply2("a", "b"), is(false)); + assertThat(Stdlib.STRING_EQUALS.apply2(null, "b"), is(false)); + + Assertions.assertThrows(RulesEvaluationError.class, () -> Stdlib.STRING_EQUALS.apply2("a", false)); + } + + @Test + public void comparesBooleans() { + assertThat(Stdlib.BOOLEAN_EQUALS.apply2(true, true), is(true)); + assertThat(Stdlib.BOOLEAN_EQUALS.apply2(true, Boolean.TRUE), is(true)); + assertThat(Stdlib.BOOLEAN_EQUALS.apply2(false, Boolean.FALSE), is(true)); + assertThat(Stdlib.BOOLEAN_EQUALS.apply2(false, false), is(true)); + + Assertions.assertThrows(RulesEvaluationError.class, () -> Stdlib.BOOLEAN_EQUALS.apply2("a", false)); + } + + @Test + public void parseUrl() throws Exception { + assertThat(Stdlib.PARSE_URL.apply1("http://foo.com"), equalTo(new URI("http://foo.com"))); + Assertions.assertThrows(RulesEvaluationError.class, () -> Stdlib.PARSE_URL.apply1(false)); + Assertions.assertThrows(RulesEvaluationError.class, () -> Stdlib.PARSE_URL.apply1("\\")); + } + + @Test + public void handlesSubstrings() { + assertThat(Stdlib.SUBSTRING.apply("abc", 0, 1, false), equalTo("a")); + assertThat(Stdlib.SUBSTRING.apply("abc", 0, 2, false), equalTo("ab")); + assertThat(Stdlib.SUBSTRING.apply("abc", 0, 3, false), equalTo("abc")); + assertThat(Stdlib.SUBSTRING.apply("abc", 1, 2, false), equalTo("b")); + assertThat(Stdlib.SUBSTRING.apply("abc", 1, 3, false), equalTo("bc")); + assertThat(Stdlib.SUBSTRING.apply("abc", 2, 3, false), equalTo("c")); + + assertThat(Stdlib.SUBSTRING.apply("abc", 2, 3, true), equalTo("a")); + assertThat(Stdlib.SUBSTRING.apply("abc", 1, 3, true), equalTo("ab")); + } + + @ParameterizedTest + @CsvSource({ + // Valid simple host labels (no dots) + "'example',false,true", + "'a',false,true", + "'server1',false,true", + "'my-host',false,true", + // Invalid simple host labels (no dots) + "'-example',false,false", + "'host_name',false,false", + "'a-very-long-host-name-that-is-exactly-64-characters-in-length-1234567',false,false", + "'',false,false", + // Valid host labels with dots + "'example.com',true,true", + "'a.b.c',true,true", + "'sub.domain.example.com',true,true", + "'192.168.1.1',true,true", + // Invalid host labels with dots + "'.example.com',true,false", // Starts with dot + "'example.com.',true,false", // Ends with dot + "'example..com',true,false", // Double dots + "'exam@ple.com',true,false", // Invalid character + "'-.example.com',true,false", // Segment starts with hyphen + "'example.c*m',true,false", // Invalid character in segment + "'a-very-long-segment-that-is-exactly-64-characters-in-length-1234567.com',true,false" // Segment too long + }) + public void testsForValidHostLabels(String input, boolean allowDots, boolean isValid) { + assertThat(input, Stdlib.IS_VALID_HOST_LABEL.apply2(input, allowDots), is(isValid)); + } + + @Test + public void resolvesSdkEndpointBuiltins() { + var ctx = Context.create(); + var endpoint = Endpoint.builder().uri("https://foo.com").build(); + ctx.put(ClientContext.CUSTOM_ENDPOINT, endpoint); + var result = Stdlib.standardBuiltins("SDK::Endpoint", ctx); + + assertThat(result, instanceOf(String.class)); + assertThat(result, equalTo(endpoint.uri().toString())); + } +} diff --git a/client/client-rulesengine/src/test/java/software/amazon/smithy/java/client/rulesengine/StringTemplateTest.java b/client/client-rulesengine/src/test/java/software/amazon/smithy/java/client/rulesengine/StringTemplateTest.java new file mode 100644 index 000000000..f8abfd436 --- /dev/null +++ b/client/client-rulesengine/src/test/java/software/amazon/smithy/java/client/rulesengine/StringTemplateTest.java @@ -0,0 +1,70 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.client.rulesengine; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.nullValue; + +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.rulesengine.language.syntax.expressions.Template; + +public class StringTemplateTest { + @Test + public void createsFromSingularExpression() { + var template = Template.fromString("{Region}"); + var st = StringTemplate.from(template); + List calls = new ArrayList<>(); + + assertThat(st.expressionCount(), is(1)); + assertThat(st.singularExpression(), notNullValue()); + assertThat(st.resolve(1, new Object[] {"test"}), equalTo("test")); + + st.forEachExpression(calls::add); + assertThat(calls, hasSize(1)); + } + + @Test + public void stEquality() { + var template = Template.fromString("foo/{Region}"); + var st1 = StringTemplate.from(template); + var st2 = StringTemplate.from(template); + var st3 = StringTemplate.from(Template.fromString("bar/{Region}")); + + assertThat(st1, equalTo(st1)); + assertThat(st2, equalTo(st1)); + assertThat(st3, not(equalTo(st1))); + } + + @Test + public void loadsTemplatesWithMixedParts() { + var template = Template.fromString("https://foo.{Region}.{Other}.com"); + var st = StringTemplate.from(template); + List calls = new ArrayList<>(); + + assertThat(st.expressionCount(), is(2)); + assertThat(st.singularExpression(), nullValue()); + assertThat(st.resolve(2, new Object[] {"abc", "def"}), equalTo("https://foo.abc.def.com")); + + st.forEachExpression(calls::add); + assertThat(calls, hasSize(2)); + } + + @Test + public void ensuresTemplatesAreNotMissing() { + var template = Template.fromString("https://foo.{Region}.{Other}.com"); + var st = StringTemplate.from(template); + + Assertions.assertThrows(RulesEvaluationError.class, () -> st.resolve(1, new Object[] {"foo"})); + } +} diff --git a/client/client-rulesengine/src/test/resources/software/amazon/smithy/java/client/rulesengine/example-complex-ruleset.json b/client/client-rulesengine/src/test/resources/software/amazon/smithy/java/client/rulesengine/example-complex-ruleset.json new file mode 100644 index 000000000..aac476a6c --- /dev/null +++ b/client/client-rulesengine/src/test/resources/software/amazon/smithy/java/client/rulesengine/example-complex-ruleset.json @@ -0,0 +1,242 @@ +{ + "version": "1.0", + "parameters": { + "Region": { + "required": false, + "type": "String" + }, + "UseFIPS": { + "required": true, + "type": "Boolean" + }, + "Endpoint": { + "builtIn": "SDK::Endpoint", + "required": false, + "documentation": "Override the endpoint used to send this request", + "type": "String" + } + }, + "rules": [ + { + "conditions": [ + { + "fn": "isSet", + "argv": [ + { + "ref": "Endpoint" + } + ] + } + ], + "rules": [ + { + "conditions": [ + { + "fn": "booleanEquals", + "argv": [ + { + "ref": "UseFIPS" + }, + true + ] + } + ], + "error": "Invalid Configuration: FIPS and custom endpoint are not supported", + "type": "error" + }, + { + "conditions": [], + "endpoint": { + "url": { + "ref": "Endpoint" + }, + "properties": {}, + "headers": {} + }, + "type": "endpoint" + } + ], + "type": "tree" + }, + { + "conditions": [ + { + "fn": "isSet", + "argv": [ + { + "ref": "Region" + } + ] + } + ], + "rules": [ + { + "conditions": [ + { + "fn": "parseURL", + "argv": [ + { + "ref": "Region" + } + ], + "assign": "PartitionResult" + } + ], + "rules": [ + { + "conditions": [ + { + "fn": "booleanEquals", + "argv": [ + true, + { + "fn": "getAttr", + "argv": [ + { + "ref": "PartitionResult" + }, + "isIp" + ] + } + ] + } + ], + "rules": [ + { + "conditions": [ + { + "fn": "booleanEquals", + "argv": [ + { + "ref": "UseFIPS" + }, + true + ] + } + ], + "rules": [ + { + "conditions": [ + { + "fn": "booleanEquals", + "argv": [ + { + "fn": "getAttr", + "argv": [ + { + "ref": "PartitionResult" + }, + "isIp" + ] + }, + true + ] + } + ], + "rules": [ + { + "conditions": [], + "endpoint": { + "url": "https://example-fips.{Region}.dual-stack-dns-suffix.com", + "properties": {}, + "headers": {} + }, + "type": "endpoint" + } + ], + "type": "tree" + }, + { + "conditions": [], + "error": "FIPS is enabled but this partition does not support FIPS", + "type": "error" + } + ], + "type": "tree" + }, + { + "conditions": [], + "endpoint": { + "url": "https://example.{Region}.dual-stack-dns-suffix.com", + "properties": {}, + "headers": {} + }, + "type": "endpoint" + } + ], + "type": "tree" + }, + { + "conditions": [ + { + "fn": "booleanEquals", + "argv": [ + { + "ref": "UseFIPS" + }, + true + ] + } + ], + "rules": [ + { + "conditions": [ + { + "fn": "booleanEquals", + "argv": [ + { + "fn": "getAttr", + "argv": [ + { + "ref": "PartitionResult" + }, + "isIp" + ] + }, + true + ] + } + ], + "rules": [ + { + "conditions": [], + "endpoint": { + "url": "https://example-fips.{Region}.dual-stack-dns-suffix.com", + "properties": {}, + "headers": {} + }, + "type": "endpoint" + } + ], + "type": "tree" + }, + { + "conditions": [], + "error": "FIPS is enabled but this partition does not support FIPS", + "type": "error" + } + ], + "type": "tree" + }, + { + "conditions": [], + "endpoint": { + "url": "https://example.{Region}.dual-stack-dns-suffix.com", + "properties": {}, + "headers": {} + }, + "type": "endpoint" + } + ], + "type": "tree" + } + ], + "type": "tree" + }, + { + "conditions": [], + "error": "Invalid Configuration: Missing Region", + "type": "error" + } + ] +} diff --git a/client/client-rulesengine/src/test/resources/software/amazon/smithy/java/client/rulesengine/minimal-ruleset.json b/client/client-rulesengine/src/test/resources/software/amazon/smithy/java/client/rulesengine/minimal-ruleset.json new file mode 100644 index 000000000..27fa326b5 --- /dev/null +++ b/client/client-rulesengine/src/test/resources/software/amazon/smithy/java/client/rulesengine/minimal-ruleset.json @@ -0,0 +1,30 @@ +{ + "version": "1.3", + "parameters": { + "Region": { + "builtIn": "AWS::Region", + "required": true, + "type": "String" + } + }, + "rules": [ + { + "conditions": [], + "documentation": "base rule", + "endpoint": { + "url": "https://{Region}.amazonaws.com", + "properties": { + "authSchemes": [ + { + "name": "sigv4", + "signingName": "serviceName", + "signingRegion": "{Region}" + } + ] + }, + "headers": {} + }, + "type": "endpoint" + } + ] +} diff --git a/client/client-rulesengine/src/test/resources/software/amazon/smithy/java/client/rulesengine/minimal-ruleset.smithy b/client/client-rulesengine/src/test/resources/software/amazon/smithy/java/client/rulesengine/minimal-ruleset.smithy new file mode 100644 index 000000000..b56e49a5a --- /dev/null +++ b/client/client-rulesengine/src/test/resources/software/amazon/smithy/java/client/rulesengine/minimal-ruleset.smithy @@ -0,0 +1,33 @@ +$version: "2.0" + +namespace example + +use smithy.rules#clientContextParams +use smithy.rules#endpointRuleSet + +@endpointRuleSet({ + "version": "1.3", + "parameters": { + "Region": { + "required": true, + "type": "String", + "documentation": "docs" + } + }, + "rules": [ + { + "conditions": [], + "documentation": "base rule", + "endpoint": { + "url": "https://{Region}.amazonaws.com", + "properties": {}, + "headers": {} + }, + "type": "endpoint" + } + ] +}) +@clientContextParams( + Region: {type: "string", documentation: "docs"} +) +service FizzBuzz {} diff --git a/client/client-rulesengine/src/test/resources/software/amazon/smithy/java/client/rulesengine/runner/default-values.smithy b/client/client-rulesengine/src/test/resources/software/amazon/smithy/java/client/rulesengine/runner/default-values.smithy new file mode 100644 index 000000000..6a30afbd7 --- /dev/null +++ b/client/client-rulesengine/src/test/resources/software/amazon/smithy/java/client/rulesengine/runner/default-values.smithy @@ -0,0 +1,136 @@ +$version: "2.0" + +namespace example + +use smithy.rules#clientContextParams +use smithy.rules#endpointRuleSet +use smithy.rules#endpointTests +use smithy.rules#staticContextParams + +@clientContextParams( + bar: {type: "string", documentation: "a client string parameter"} + baz: {type: "string", documentation: "another client string parameter"} +) +@endpointRuleSet({ + version: "1.0", + parameters: { + bar: { + type: "string", + documentation: "docs" + } + baz: { + type: "string", + documentation: "docs" + required: true + default: "baz" + }, + stringArrayParam: { + type: "stringArray", + required: true, + default: ["a", "b", "c"], + documentation: "docs" + } + }, + rules: [ + { + "documentation": "Template baz into URI when bar is set", + "conditions": [ + { + "fn": "isSet", + "argv": [ + { + "ref": "bar" + } + ] + } + ], + "endpoint": { + "url": "https://example.com/{baz}" + }, + "type": "endpoint" + }, + { + "documentation": "Template first array value into URI", + "conditions": [ + { + "fn": "getAttr", + "argv": [ + { + "ref": "stringArrayParam" + }, + "[0]" + ], + "assign": "arrayValue" + } + ], + "endpoint": { + "url": "https://example.com/{arrayValue}" + }, + "type": "endpoint" + }, + { + "conditions": [], + "documentation": "error fallthrough", + "error": "endpoint error", + "type": "error" + } + ] +}) +@endpointTests({ + "version": "1.0", + "testCases": [ + { + "documentation": "a b" + "params": { + "bar": "a b", + } + "expect": { + "endpoint": { + "url": "https://example.com/baz" + } + } + }, + { + "documentation": "BIG" + "params": { + "bar": "a b", + "baz": "BIG" + } + "expect": { + "endpoint": { + "url": "https://example.com/BIG" + } + } + }, + { + "documentation": "Default array values used" + "params": { + } + "expect": { + "endpoint": { + "url": "https://example.com/a" + } + } + }, + { + "params": { + "stringArrayParam": [] + } + "documentation": "a documentation string", + "expect": { + "error": "endpoint error" + } + } + ] +}) +service FizzBuzz { + version: "2022-01-01", + operations: [GetThing] +} + +@staticContextParams( + "stringArrayParam": {value: []} +) +operation GetThing { + input := {} +} diff --git a/client/client-rulesengine/src/test/resources/software/amazon/smithy/java/client/rulesengine/runner/headers.smithy b/client/client-rulesengine/src/test/resources/software/amazon/smithy/java/client/rulesengine/runner/headers.smithy new file mode 100644 index 000000000..5587c8be3 --- /dev/null +++ b/client/client-rulesengine/src/test/resources/software/amazon/smithy/java/client/rulesengine/runner/headers.smithy @@ -0,0 +1,80 @@ +$version: "2.0" + +namespace example + +use smithy.rules#clientContextParams +use smithy.rules#endpointRuleSet +use smithy.rules#endpointTests + +@endpointRuleSet({ + "parameters": { + "Region": { + "type": "string", + "documentation": "The region to dispatch this request, eg. `us-east-1`." + } + }, + "rules": [ + { + "documentation": "Template the region into the URI when region is set", + "conditions": [ + { + "fn": "isSet", + "argv": [ + { + "ref": "Region" + } + ] + } + ], + "endpoint": { + "url": "https://{Region}.amazonaws.com", + "headers": { + "x-amz-region": [ + "{Region}" + ], + "x-amz-multi": [ + "*", + "{Region}" + ] + } + }, + "type": "endpoint" + }, + { + "documentation": "fallback when region is unset", + "conditions": [], + "error": "Region must be set to resolve a valid endpoint", + "type": "error" + } + ], + "version": "1.3" +}) +@endpointTests( + "version": "1.0", + "testCases": [ + { + "documentation": "header set to region", + "params": { + "Region": "us-east-1" + }, + "expect": { + "endpoint": { + "url": "https://us-east-1.amazonaws.com", + "headers": { + "x-amz-region": [ + "us-east-1" + ], + "x-amz-multi": [ + "*", + "us-east-1" + ] + } + } + } + } + ] +) +@clientContextParams( + Region: {type: "string", documentation: "docs"} +) +service FizzBuzz {} diff --git a/client/client-rulesengine/src/test/resources/software/amazon/smithy/java/client/rulesengine/runner/parse-url.smithy b/client/client-rulesengine/src/test/resources/software/amazon/smithy/java/client/rulesengine/runner/parse-url.smithy new file mode 100644 index 000000000..00e8b371f --- /dev/null +++ b/client/client-rulesengine/src/test/resources/software/amazon/smithy/java/client/rulesengine/runner/parse-url.smithy @@ -0,0 +1,246 @@ +$version: "2.0" + +namespace example + +use smithy.rules#clientContextParams +use smithy.rules#endpointRuleSet +use smithy.rules#endpointTests + +@endpointRuleSet({ + "version": "1.3", + "parameters": { + "Endpoint": { + "type": "string", + "documentation": "docs" + } + }, + "rules": [ + { + "documentation": "endpoint is set and is a valid URL", + "conditions": [ + { + "fn": "isSet", + "argv": [ + { + "ref": "Endpoint" + } + ] + }, + { + "fn": "parseURL", + "argv": [ + "{Endpoint}" + ], + "assign": "url" + } + ], + "rules": [ + { + "conditions": [ + { + "fn": "booleanEquals", + "argv": [ + { + "fn": "getAttr", + "argv": [ + { + "ref": "url" + }, + "isIp" + ] + }, + true + ] + } + ], + "endpoint": { + "url": "{url#scheme}://{url#authority}{url#normalizedPath}is-ip-addr" + }, + "type": "endpoint" + }, + { + "conditions": [ + { + "fn": "stringEquals", + "argv": [ + "{url#path}", + "/port" + ] + } + ], + "endpoint": { + "url": "{url#scheme}://{url#authority}/uri-with-port" + }, + "type": "endpoint" + }, + { + "conditions": [ + { + "fn": "stringEquals", + "argv": [ + "{url#normalizedPath}", + "/" + ] + } + ], + "endpoint": { + "url": "https://{url#scheme}-{url#authority}-nopath.example.com" + }, + "type": "endpoint" + }, + { + "conditions": [], + "endpoint": { + "url": "https://{url#scheme}-{url#authority}.example.com/path-is{url#path}" + }, + "type": "endpoint" + } + ], + "type": "tree" + }, + { + "error": "endpoint was invalid", + "conditions": [], + "type": "error" + } + ] +}) +@endpointTests( + version: "1.0", + testCases: [ + { + "documentation": "simple URL parsing", + "params": { + "Endpoint": "https://authority.com/custom-path" + }, + "expect": { + "endpoint": { + "url": "https://https-authority.com.example.com/path-is/custom-path" + } + } + }, + { + "documentation": "empty path no slash", + "params": { + "Endpoint": "https://authority.com" + }, + "expect": { + "endpoint": { + "url": "https://https-authority.com-nopath.example.com" + } + } + }, + { + "documentation": "empty path with slash", + "params": { + "Endpoint": "https://authority.com/" + }, + "expect": { + "endpoint": { + "url": "https://https-authority.com-nopath.example.com" + } + } + }, + { + "documentation": "authority with port", + "params": { + "Endpoint": "https://authority.com:8000/port" + }, + "expect": { + "endpoint": { + "url": "https://authority.com:8000/uri-with-port" + } + } + }, + { + "documentation": "http schemes", + "params": { + "Endpoint": "http://authority.com:8000/port" + }, + "expect": { + "endpoint": { + "url": "http://authority.com:8000/uri-with-port" + } + } + }, + { + "documentation": "host labels are not validated", + "params": { + "Endpoint": "http://99_ab.com" + }, + "expect": { + "endpoint": { + "url": "https://http-99_ab.com-nopath.example.com" + } + } + }, + { + "documentation": "host labels are not validated", + "params": { + "Endpoint": "http://99_ab-.com" + }, + "expect": { + "endpoint": { + "url": "https://http-99_ab-.com-nopath.example.com" + } + } + }, + { + "documentation": "IP Address", + "params": { + "Endpoint": "http://192.168.1.1/foo/" + }, + "expect": { + "endpoint": { + "url": "http://192.168.1.1/foo/is-ip-addr" + } + } + }, + { + "documentation": "IP Address with port", + "params": { + "Endpoint": "http://192.168.1.1:1234/foo/" + }, + "expect": { + "endpoint": { + "url": "http://192.168.1.1:1234/foo/is-ip-addr" + } + } + }, + { + "documentation": "IPv6 Address", + "params": { + "Endpoint": "https://[2001:db8:85a3:8d3:1319:8a2e:370:7348]:443" + }, + "expect": { + "endpoint": { + "url": "https://[2001:db8:85a3:8d3:1319:8a2e:370:7348]:443/is-ip-addr" + } + } + }, + { + "documentation": "weird DNS name", + "params": { + "Endpoint": "https://999.999.abc.blah" + }, + "expect": { + "endpoint": { + "url": "https://https-999.999.abc.blah-nopath.example.com" + } + } + }, + { + "documentation": "query in resolved endpoint is not supported", + "params": { + "Endpoint": "https://example.com/path?query1=foo" + }, + "expect": { + "error": "endpoint was invalid" + } + } + ] +) +@clientContextParams( + Endpoint: {type: "string", documentation: "docs"} +) +service FizzBuzz {} diff --git a/client/client-rulesengine/src/test/resources/software/amazon/smithy/java/client/rulesengine/runner/ruleset-with-params.smithy b/client/client-rulesengine/src/test/resources/software/amazon/smithy/java/client/rulesengine/runner/ruleset-with-params.smithy new file mode 100644 index 000000000..8fb8a0f26 --- /dev/null +++ b/client/client-rulesengine/src/test/resources/software/amazon/smithy/java/client/rulesengine/runner/ruleset-with-params.smithy @@ -0,0 +1,193 @@ +$version: "1.0" + +namespace example + +use smithy.rules#contextParam +use smithy.rules#endpointRuleSet +use smithy.rules#staticContextParams +use smithy.rules#endpointTests +use smithy.rules#operationContextParams + +@endpointRuleSet({ + "version": "1.3", + "parameters": { + "ParameterFoo": { + "type": "string", + "documentation": "docs" + }, + "ParameterBar": { + "type": "string", + "documentation": "docs" + } + "ParameterBaz": { + "type": "string", + "documentation": "docs" + } + }, + "rules": [ + { + "conditions": [ + { + "fn": "isSet", + "argv": [ + { + "ref": "ParameterFoo" + } + ] + } + ], + "type": "tree", + "rules": [ + { + "conditions": [ + { + "fn": "isSet", + "argv": [ + { + "ref": "ParameterBaz" // provided via jmespath + } + ] + } + ], + "endpoint": { + "url": "https://{ParameterBaz}.baz.amazonaws.com" + }, + "type": "endpoint" + } + { + "conditions": [ + { + "fn": "isSet", + "argv": [ + { + "ref": "ParameterBar" + } + ] + } + ], + "endpoint": { + "url": "https://{ParameterBar}.amazonaws.com" + }, + "type": "endpoint" + }, + { + "conditions": [] + "endpoint": { + "url": "https://{ParameterFoo}.amazonaws.com" + }, + "type": "endpoint" + } + ] + } + { + "type": "error", + "conditions": [], + "error": "No rule matched" + } + ] +}) +@endpointTests( + version: "1.0", + testCases: [ + { + "documentation": "context param" + "params": { + "ParameterFoo": "foo" + }, + "expect": { + "endpoint": { + "url": "https://foo.amazonaws.com" + } + } + "operationInputs": [ + { + "operationName": "GetResource" + "operationParams": {} + } + ] + } + { + "documentation": "grabs operation context param" + "params": { + "ParameterFoo": "foo" + "ParameterBar": "hello" + }, + "expect": { + "endpoint": { + "url": "https://hello.amazonaws.com" + } + } + "operationInputs": [ + { + "operationName": "GetResource" + "operationParams": { + "bar": "hello" + } + } + ] + } + { + "documentation": "falls back to last condition when no params were set" + "params": { + "ParameterFoo": "foo" + }, + "expect": { + "endpoint": { + "url": "https://foo.amazonaws.com" + } + } + "operationInputs": [ + { + "operationName": "GetResource" + "operationParams": {} + } + ] + } + { + "documentation": "uses jmespath context params" + "params": { + "ParameterFoo": "foo" // static context param + "ParameterBaz": "bbaz" // jmespath extraction + }, + "expect": { + "endpoint": { + "url": "https://bbaz.baz.amazonaws.com" + } + } + "operationInputs": [ + { + "operationName": "GetResource" + "operationParams": { + baz: { + bar: "bbaz" + } + } + } + ] + } + ] +) +service FizzBuzz { + operations: [GetResource] +} + +@staticContextParams("ParameterFoo": {value: "foo"}) +@operationContextParams( + ParameterBaz: { + path: "baz.bar" + } +) +operation GetResource { + input: GetResourceInput +} + +structure GetResourceInput { + @contextParam(name: "ParameterBar") + bar: String + + baz: Baz +} + +structure Baz { + bar: String +} diff --git a/client/client-rulesengine/src/test/resources/software/amazon/smithy/java/client/rulesengine/runner/substring.smithy b/client/client-rulesengine/src/test/resources/software/amazon/smithy/java/client/rulesengine/runner/substring.smithy new file mode 100644 index 000000000..42d93b922 --- /dev/null +++ b/client/client-rulesengine/src/test/resources/software/amazon/smithy/java/client/rulesengine/runner/substring.smithy @@ -0,0 +1,293 @@ +$version: "2.0" + +namespace example + +use smithy.rules#clientContextParams +use smithy.rules#endpointRuleSet +use smithy.rules#endpointTests + +@endpointRuleSet({ + "parameters": { + "TestCaseId": { + "type": "string", + "required": true, + "documentation": "Test case id used to select the test case to use" + }, + "Input": { + "type": "string", + "required": true, + "documentation": "the input used to test substring" + } + }, + "rules": [ + { + "documentation": "Substring from beginning of input", + "conditions": [ + { + "fn": "stringEquals", + "argv": [ + "{TestCaseId}", + "1" + ] + }, + { + "fn": "substring", + "argv": [ + "{Input}", + 0, + 4, + false + ], + "assign": "output" + } + ], + "error": "The value is: `{output}`", + "type": "error" + }, + { + "documentation": "Substring from end of input", + "conditions": [ + { + "fn": "stringEquals", + "argv": [ + "{TestCaseId}", + "2" + ] + }, + { + "fn": "substring", + "argv": [ + "{Input}", + 0, + 4, + true + ], + "assign": "output" + } + ], + "error": "The value is: `{output}`", + "type": "error" + }, + { + "documentation": "Substring the middle of the string", + "conditions": [ + { + "fn": "stringEquals", + "argv": [ + "{TestCaseId}", + "3" + ] + }, + { + "fn": "substring", + "argv": [ + "{Input}", + 1, + 3, + false + ], + "assign": "output" + } + ], + "error": "The value is: `{output}`", + "type": "error" + }, + { + "documentation": "fallback when no tests match", + "conditions": [], + "error": "No tests matched", + "type": "error" + } + ], + "version": "1.3" +}) +@endpointTests( + version: "1.0", + testCases: [ + { + "documentation": "substring when string is long enough", + "params": { + "TestCaseId": "1", + "Input": "abcdefg" + }, + "expect": { + "error": "The value is: `abcd`" + } + }, + { + "documentation": "substring when string is exactly the right length", + "params": { + "TestCaseId": "1", + "Input": "abcd" + }, + "expect": { + "error": "The value is: `abcd`" + } + }, + { + "documentation": "substring when string is too short", + "params": { + "TestCaseId": "1", + "Input": "abc" + }, + "expect": { + "error": "No tests matched" + } + }, + { + "documentation": "substring when string is too short", + "params": { + "TestCaseId": "1", + "Input": "" + }, + "expect": { + "error": "No tests matched" + } + }, + { + "documentation": "substring on wide characters (ensure that unicode code points are properly counted)", + "params": { + "TestCaseId": "1", + "Input": "\ufdfd" + }, + "expect": { + "error": "No tests matched" + } + }, + { + "documentation": "unicode characters always return `None`", + "params": { + "TestCaseId": "1", + "Input": "abcdef\uD83D\uDC31" + }, + "expect": { + "error": "No tests matched" + } + }, + { + "documentation": "non-ascii cause substring to always return `None`", + "params": { + "TestCaseId": "1", + "Input": "abcdef\u0080" + }, + "expect": { + "error": "No tests matched" + } + }, + { + "documentation": "the full set of ascii is supported, including non-printable characters", + "params": { + "TestCaseId": "1", + "Input": "\u007Fabcdef" + }, + "expect": { + "error": "The value is: `\u007Fabc`" + } + }, + { + "documentation": "substring when string is long enough", + "params": { + "TestCaseId": "2", + "Input": "abcdefg" + }, + "expect": { + "error": "The value is: `defg`" + } + }, + { + "documentation": "substring when string is exactly the right length", + "params": { + "TestCaseId": "2", + "Input": "defg" + }, + "expect": { + "error": "The value is: `defg`" + } + }, + { + "documentation": "substring when string is too short", + "params": { + "TestCaseId": "2", + "Input": "abc" + }, + "expect": { + "error": "No tests matched" + } + }, + { + "documentation": "substring when string is too short", + "params": { + "TestCaseId": "2", + "Input": "" + }, + "expect": { + "error": "No tests matched" + } + }, + { + "documentation": "substring on wide characters (ensure that unicode code points are properly counted)", + "params": { + "TestCaseId": "2", + "Input": "\ufdfd" + }, + "expect": { + "error": "No tests matched" + } + }, + { + "documentation": "substring when string is longer", + "params": { + "TestCaseId": "3", + "Input": "defg" + }, + "expect": { + "error": "The value is: `ef`" + } + }, + { + "documentation": "substring when string is exact length", + "params": { + "TestCaseId": "3", + "Input": "def" + }, + "expect": { + "error": "The value is: `ef`" + } + }, + { + "documentation": "substring when string is too short", + "params": { + "TestCaseId": "3", + "Input": "ab" + }, + "expect": { + "error": "No tests matched" + } + }, + { + "documentation": "substring when string is too short", + "params": { + "TestCaseId": "3", + "Input": "" + }, + "expect": { + "error": "No tests matched" + } + }, + { + "documentation": "substring on wide characters (ensure that unicode code points are properly counted)", + "params": { + "TestCaseId": "3", + "Input": "\ufdfd" + }, + "expect": { + "error": "No tests matched" + } + } + ] +) +@clientContextParams( + TestCaseId: {type: "string", documentation: "docs"} + Input: {type: "string", documentation: "docs"} +) +service FizzBuzz {} diff --git a/client/client-rulesengine/src/test/resources/software/amazon/smithy/java/client/rulesengine/runner/uri-encode.smithy b/client/client-rulesengine/src/test/resources/software/amazon/smithy/java/client/rulesengine/runner/uri-encode.smithy new file mode 100644 index 000000000..6d2ef57f6 --- /dev/null +++ b/client/client-rulesengine/src/test/resources/software/amazon/smithy/java/client/rulesengine/runner/uri-encode.smithy @@ -0,0 +1,132 @@ +$version: "2.0" + +namespace example + +use smithy.rules#clientContextParams +use smithy.rules#endpointRuleSet +use smithy.rules#endpointTests + +@endpointRuleSet({ + "version": "1.3", + "parameters": { + "TestCaseId": { + "type": "string", + "required": true, + "documentation": "Test case id used to select the test case to use" + }, + "Input": { + "type": "string", + "required": true, + "documentation": "The input used to test uriEncode" + } + }, + "rules": [ + { + "documentation": "uriEncode on input", + "conditions": [ + { + "fn": "stringEquals", + "argv": [ + "{TestCaseId}", + "1" + ] + }, + { + "fn": "uriEncode", + "argv": [ + "{Input}" + ], + "assign": "output" + } + ], + "error": "The value is: `{output}`", + "type": "error" + }, + { + "documentation": "fallback when no tests match", + "conditions": [], + "error": "No tests matched", + "type": "error" + } + ] +}) +@endpointTests( + version: "1.0", + testCases: [ + { + "documentation": "uriEncode when the string has nothing to encode returns the input", + "params": { + "TestCaseId": "1", + "Input": "abcdefg" + }, + "expect": { + "error": "The value is: `abcdefg`" + } + }, + { + "documentation": "uriEncode with single character to encode encodes only that character", + "params": { + "TestCaseId": "1", + "Input": "abc:defg" + }, + "expect": { + "error": "The value is: `abc%3Adefg`" + } + }, + { + "documentation": "uriEncode with all ASCII characters to encode encodes all characters", + "params": { + "TestCaseId": "1", + "Input": "/:,?#[]{}|@! $&'()*+;=%<>\"^`\\" + }, + "expect": { + "error": "The value is: `%2F%3A%2C%3F%23%5B%5D%7B%7D%7C%40%21%20%24%26%27%28%29%2A%2B%3B%3D%25%3C%3E%22%5E%60%5C`" + } + }, + { + "documentation": "uriEncode with ASCII characters that should not be encoded returns the input", + "params": { + "TestCaseId": "1", + "Input": "0123456789.underscore_dash-Tilda~" + }, + "expect": { + "error": "The value is: `0123456789.underscore_dash-Tilda~`" + } + }, + { + "documentation": "uriEncode encodes unicode characters", + "params": { + "TestCaseId": "1", + "Input": "\ud83d\ude39" + }, + "expect": { + "error": "The value is: `%F0%9F%98%B9`" + } + }, + { + "documentation": "uriEncode on all printable ASCII characters", + "params": { + "TestCaseId": "1", + "Input": " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~" + }, + "expect": { + "error": "The value is: `%20%21%22%23%24%25%26%27%28%29%2A%2B%2C-.%2F0123456789%3A%3B%3C%3D%3E%3F%40ABCDEFGHIJKLMNOPQRSTUVWXYZ%5B%5C%5D%5E_%60abcdefghijklmnopqrstuvwxyz%7B%7C%7D~`" + } + }, + { + "documentation": "uriEncode on an empty string", + "params": { + "TestCaseId": "1", + "Input": "" + }, + "expect": { + "error": "The value is: ``" + } + } + ] +) +@clientContextParams( + TestCaseId: {type: "string", documentation: "Test case id used to select the test case to use"}, + Input: {type: "string", documentation: "The input used to test uriEncoder"} +) +service FizzBuzz {} diff --git a/client/client-rulesengine/src/test/resources/software/amazon/smithy/java/client/rulesengine/runner/valid-hostlabel.smithy b/client/client-rulesengine/src/test/resources/software/amazon/smithy/java/client/rulesengine/runner/valid-hostlabel.smithy new file mode 100644 index 000000000..41f17593f --- /dev/null +++ b/client/client-rulesengine/src/test/resources/software/amazon/smithy/java/client/rulesengine/runner/valid-hostlabel.smithy @@ -0,0 +1,149 @@ +$version: "2.0" + +namespace example + +use smithy.rules#clientContextParams +use smithy.rules#endpointRuleSet +use smithy.rules#endpointTests + +@endpointRuleSet({ + "parameters": { + "Region": { + "type": "string", + "required": true, + "documentation": "The region to dispatch this request, eg. `us-east-1`." + } + }, + "rules": [ + { + "documentation": "Template the region into the URI when region is set", + "conditions": [ + { + "fn": "isValidHostLabel", + "argv": [ + { + "ref": "Region" + }, + false + ] + } + ], + "endpoint": { + "url": "https://{Region}.amazonaws.com" + }, + "type": "endpoint" + }, + { + "documentation": "Template the region into the URI when region is set", + "conditions": [ + { + "fn": "isValidHostLabel", + "argv": [ + { + "ref": "Region" + }, + true + ] + } + ], + "endpoint": { + "url": "https://{Region}-subdomains.amazonaws.com" + }, + "type": "endpoint" + }, + { + "documentation": "Region was not a valid host label", + "conditions": [], + "error": "Invalid hostlabel", + "type": "error" + } + ], + "version": "1.3" +}) +@endpointTests( + version: "1.0", + testCases: [ + { + "documentation": "standard region is a valid hostlabel", + "params": { + "Region": "us-east-1" + }, + "expect": { + "endpoint": { + "url": "https://us-east-1.amazonaws.com" + } + } + }, + { + "documentation": "starting with a number is a valid hostlabel", + "params": { + "Region": "3aws4" + }, + "expect": { + "endpoint": { + "url": "https://3aws4.amazonaws.com" + } + } + }, + { + "documentation": "when there are dots, only match if subdomains are allowed", + "params": { + "Region": "part1.part2" + }, + "expect": { + "endpoint": { + "url": "https://part1.part2-subdomains.amazonaws.com" + } + } + }, + { + "documentation": "a space is never a valid hostlabel", + "params": { + "Region": "part1 part2" + }, + "expect": { + "error": "Invalid hostlabel" + } + }, + { + "documentation": "an empty string is not a valid hostlabel", + "params": { + "Region": "" + }, + "expect": { + "error": "Invalid hostlabel" + } + }, + { + "documentation": "ending with a dot is not a valid hostlabel", + "params": { + "Region": "part1." + }, + "expect": { + "error": "Invalid hostlabel" + } + }, + { + "documentation": "multiple consecutive dots are not allowed", + "params": { + "Region": "part1..part2" + }, + "expect": { + "error": "Invalid hostlabel" + } + }, + { + "documentation": "labels cannot start with a dash", + "params": { + "Region": "part1.-part2" + }, + "expect": { + "error": "Invalid hostlabel" + } + } + ] +) +@clientContextParams( + Region: {type: "string", documentation: "docs"} +) +service FizzBuzz {} diff --git a/client/client-rulesengine/src/test/resources/software/amazon/smithy/java/client/rulesengine/substring.json b/client/client-rulesengine/src/test/resources/software/amazon/smithy/java/client/rulesengine/substring.json new file mode 100644 index 000000000..b60ca0935 --- /dev/null +++ b/client/client-rulesengine/src/test/resources/software/amazon/smithy/java/client/rulesengine/substring.json @@ -0,0 +1,95 @@ +{ + "parameters": { + "TestCaseId": { + "type": "string", + "required": true, + "documentation": "Test case id used to select the test case to use" + }, + "Input": { + "type": "string", + "required": true, + "documentation": "the input used to test substring" + } + }, + "rules": [ + { + "documentation": "Substring from beginning of input", + "conditions": [ + { + "fn": "stringEquals", + "argv": [ + "{TestCaseId}", + "1" + ] + }, + { + "fn": "substring", + "argv": [ + "{Input}", + 0, + 4, + false + ], + "assign": "output" + } + ], + "error": "The value is: `{output}`", + "type": "error" + }, + { + "documentation": "Substring from end of input", + "conditions": [ + { + "fn": "stringEquals", + "argv": [ + "{TestCaseId}", + "2" + ] + }, + { + "fn": "substring", + "argv": [ + "{Input}", + 0, + 4, + true + ], + "assign": "output" + } + ], + "error": "The value is: `{output}`", + "type": "error" + }, + { + "documentation": "Substring the middle of the string", + "conditions": [ + { + "fn": "stringEquals", + "argv": [ + "{TestCaseId}", + "3" + ] + }, + { + "fn": "substring", + "argv": [ + "{Input}", + 1, + 3, + false + ], + "assign": "output" + } + ], + "error": "The value is: `{output}`", + "type": "error" + }, + { + "documentation": "fallback when no tests match", + "conditions": [], + "error": "No tests matched", + "type": "error" + } + ], + "version": "1.3" +} diff --git a/config/spotbugs/filter.xml b/config/spotbugs/filter.xml index e5c522790..2e935a335 100644 --- a/config/spotbugs/filter.xml +++ b/config/spotbugs/filter.xml @@ -76,4 +76,9 @@ + + + + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d08a92659..a7df19ea7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -31,6 +31,8 @@ smithy-validation-model = { module = "software.amazon.smithy:smithy-validation-m smithy-jmespath = { module = "software.amazon.smithy:smithy-jmespath", version.ref = "smithy" } smithy-waiters = { module = "software.amazon.smithy:smithy-waiters", version.ref = "smithy" } smithy-utils = { module = "software.amazon.smithy:smithy-utils", version.ref = "smithy" } +smithy-rules = { module = "software.amazon.smithy:smithy-rules-engine", version.ref = "smithy" } +smithy-aws-endpoints = { module = "software.amazon.smithy:smithy-aws-endpoints", version.ref = "smithy" } # AWS SDK for Java V2 adapters. aws-sdk-retries-spi = {module = "software.amazon.awssdk:retries-spi", version.ref = "aws-sdk"} diff --git a/io/src/main/java/software/amazon/smithy/java/io/uri/URLEncoding.java b/io/src/main/java/software/amazon/smithy/java/io/uri/URLEncoding.java index 89aa1056e..540b2f178 100644 --- a/io/src/main/java/software/amazon/smithy/java/io/uri/URLEncoding.java +++ b/io/src/main/java/software/amazon/smithy/java/io/uri/URLEncoding.java @@ -43,7 +43,7 @@ public static void encodeUnreserved(String source, StringBuilder sink, boolean i sink.append(c); } case '2' -> { - if (i < result.length() - 1 && result.charAt(i + 2) == 'F' && ignoreSlashes) { + if (ignoreSlashes && i < result.length() - 1 && result.charAt(i + 2) == 'F') { sink.append('/'); i += 2; break; diff --git a/settings.gradle.kts b/settings.gradle.kts index b41b81968..6e693e817 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -33,6 +33,7 @@ include(":client:client-rpcv2-cbor") include(":client:dynamic-client") include(":client:client-mock-plugin") include(":client:client-waiters") +include(":client:client-rulesengine") // Server include(":server:server-api") @@ -61,6 +62,7 @@ include(":aws:client:aws-client-core") include(":aws:client:aws-client-http") include(":aws:client:aws-client-restjson") include(":aws:client:aws-client-restxml") +include(":aws:client:aws-client-rulesengine") include(":aws:integrations:aws-lambda-endpoint") include(":aws:server:aws-server-restjson") include(":aws:aws-auth-api") @@ -97,4 +99,4 @@ include(":mcp:mcp-cli-api") include(":mcp:mcp-bundle-api") include(":model-bundle") -include(":model-bundle:model-bundle-api") \ No newline at end of file +include(":model-bundle:model-bundle-api")