From aa8bf5a84e3f826ffa4d02514e83f2cd2b1daf15 Mon Sep 17 00:00:00 2001 From: Alex Woods Date: Thu, 16 Jul 2026 10:56:53 -0700 Subject: [PATCH 1/5] =?UTF-8?q?perf(codegen):=20Add=20EndpointUrlCodegenAn?= =?UTF-8?q?alyzer=20for=20compile-time=20URL=20pre-parsing=20Introduce=20E?= =?UTF-8?q?ndpointUrlCodegenAnalyzer=20which=20analyzes=20URL=20expression?= =?UTF-8?q?=20trees=20at=20code=20generation=20time=20to=20determine=20if?= =?UTF-8?q?=20URL=20components=20(scheme,=20host,=20port,=20path)=20can=20?= =?UTF-8?q?be=20statically=20decomposed.=20When=20they=20can,=20the=20code?= =?UTF-8?q?gen=20emits=20EndpointUrl.fromComponents(scheme,=20hostExpr,=20?= =?UTF-8?q?port,=20path)=20instead=20of=20EndpointUrl.fromString(fullUrl),?= =?UTF-8?q?=20eliminating=20all=20runtime=20string=20parsing.=20This=20app?= =?UTF-8?q?lies=20to=20the=20vast=20majority=20of=20AWS=20service=20endpoi?= =?UTF-8?q?nts=20which=20follow=20the=20pattern=20https://{service}.{regio?= =?UTF-8?q?n}.{dnsSuffix}=20=E2=80=94=20these=20get=20zero=20runtime=20par?= =?UTF-8?q?sing=20cost.=20URLs=20with=20dynamic=20path=20components=20(e.g?= =?UTF-8?q?.,=20S3=20path-style)=20fall=20back=20to=20EndpointUrl.fromStri?= =?UTF-8?q?ng()=20safely.=20Update=20CodeGeneratorVisitor.visitEndpointExp?= =?UTF-8?q?ression()=20to=20invoke=20the=20analyzer=20and=20select=20the?= =?UTF-8?q?=20optimal=20code=20path.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../poet/rules2/CodeGeneratorVisitor.java | 20 +- .../rules2/EndpointUrlCodegenAnalyzer.java | 359 ++++++++++++++++++ .../EndpointUrlCodegenAnalyzerTest.java | 184 +++++++++ .../poet/rules2/endpoint-provider-class.java | 22 +- ...int-provider-know-prop-override-class.java | 22 +- ...endpoint-provider-metric-values-class.java | 71 ++-- .../endpoint-provider-uri-cache-class.java | 22 +- 7 files changed, 640 insertions(+), 60 deletions(-) create mode 100644 codegen/src/main/java/software/amazon/awssdk/codegen/poet/rules2/EndpointUrlCodegenAnalyzer.java create mode 100644 codegen/src/test/java/software/amazon/awssdk/codegen/poet/rules2/EndpointUrlCodegenAnalyzerTest.java diff --git a/codegen/src/main/java/software/amazon/awssdk/codegen/poet/rules2/CodeGeneratorVisitor.java b/codegen/src/main/java/software/amazon/awssdk/codegen/poet/rules2/CodeGeneratorVisitor.java index 624173bde3a1..fb1e6611e9a4 100644 --- a/codegen/src/main/java/software/amazon/awssdk/codegen/poet/rules2/CodeGeneratorVisitor.java +++ b/codegen/src/main/java/software/amazon/awssdk/codegen/poet/rules2/CodeGeneratorVisitor.java @@ -329,9 +329,23 @@ private String callParams(String ruleId) { @Override public Void visitEndpointExpression(EndpointExpression e) { builder.add("return $T.endpoint(", typeMirror.rulesResult().type()); - builder.add("$T.builder().endpointUrl($T.fromString(", Endpoint.class, EndpointUrl.class); - e.url().accept(this); - builder.add("))"); + + EndpointUrlCodegenAnalyzer.AnalysisResult analysis = + EndpointUrlCodegenAnalyzer.analyze(e.url()); + + if (analysis.isPreParseable()) { + // Emit EndpointUrl.fromComponents(scheme, hostExpr, port, path) — no runtime parsing + builder.add("$T.builder().endpointUrl($T.fromComponents($S, ", + Endpoint.class, EndpointUrl.class, analysis.scheme()); + analysis.hostExpr().accept(this); + builder.add(", $L, $S))", analysis.port(), analysis.encodedPath()); + } else { + // Fallback: emit EndpointUrl.fromString(fullUrl) — lightweight runtime parsing + builder.add("$T.builder().endpointUrl($T.fromString(", Endpoint.class, EndpointUrl.class); + e.url().accept(this); + builder.add("))"); + } + e.headers().accept(this); e.properties().accept(this); builder.add(".build()"); diff --git a/codegen/src/main/java/software/amazon/awssdk/codegen/poet/rules2/EndpointUrlCodegenAnalyzer.java b/codegen/src/main/java/software/amazon/awssdk/codegen/poet/rules2/EndpointUrlCodegenAnalyzer.java new file mode 100644 index 000000000000..59c020d7ef7e --- /dev/null +++ b/codegen/src/main/java/software/amazon/awssdk/codegen/poet/rules2/EndpointUrlCodegenAnalyzer.java @@ -0,0 +1,359 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.codegen.poet.rules2; + +import java.util.ArrayList; +import java.util.List; + +/** + * Analyzes an endpoint URL expression at codegen time to determine if its components + * (scheme, host, port, path) can be statically decomposed, enabling the use of + * {@code EndpointUrl.fromComponents()} instead of {@code EndpointUrl.fromString()} in generated code. + */ +final class EndpointUrlCodegenAnalyzer { + + private static final String HTTPS_SCHEME_PREFIX = "https://"; + private static final String HTTP_SCHEME_PREFIX = "http://"; + + private EndpointUrlCodegenAnalyzer() { + } + + /** + * Analyze the URL expression from an EndpointExpression. + * + * @param urlExpr the URL expression (typically a StringConcatExpression or LiteralStringExpression) + * @return an AnalysisResult indicating whether pre-parsing is possible and the decomposed components + */ + static AnalysisResult analyze(RuleExpression urlExpr) { + if (urlExpr instanceof LiteralStringExpression) { + return analyzeStaticUrl(((LiteralStringExpression) urlExpr).value()); + } + if (!(urlExpr instanceof StringConcatExpression)) { + return AnalysisResult.notPreParseable(); + } + return analyzeStringConcat((StringConcatExpression) urlExpr); + } + + /** + * Analyze a fully static URL string (from a LiteralStringExpression). + */ + private static AnalysisResult analyzeStaticUrl(String url) { + int schemeEnd = url.indexOf("://"); + if (schemeEnd < 0) { + return AnalysisResult.notPreParseable(); + } + + String scheme = url.substring(0, schemeEnd); + int authorityStart = schemeEnd + 3; + int pathStart = url.indexOf('/', authorityStart); + + String authority; + String encodedPath; + if (pathStart < 0) { + authority = url.substring(authorityStart); + encodedPath = ""; + } else { + authority = url.substring(authorityStart, pathStart); + encodedPath = url.substring(pathStart); + } + + int port = -1; + String host; + int colonPos = authority.lastIndexOf(':'); + if (colonPos >= 0) { + host = authority.substring(0, colonPos); + try { + port = Integer.parseInt(authority.substring(colonPos + 1)); + } catch (NumberFormatException e) { + // Not a valid port — treat the whole authority as the host + host = authority; + } + } else { + host = authority; + } + + RuleExpression hostExpr = new LiteralStringExpression(host); + return new AnalysisResult(true, scheme, hostExpr, port, encodedPath); + } + + /** + * Analyze a StringConcatExpression URL template. + */ + private static AnalysisResult analyzeStringConcat(StringConcatExpression concatExpr) { + List expressions = concatExpr.expressions(); + if (expressions.isEmpty()) { + return AnalysisResult.notPreParseable(); + } + + // Step 1: The first expression must be a literal starting with a scheme + RuleExpression firstExpr = expressions.get(0); + if (!(firstExpr instanceof LiteralStringExpression)) { + return AnalysisResult.notPreParseable(); + } + + String firstLiteral = ((LiteralStringExpression) firstExpr).value(); + String scheme; + String hostStartStr; + if (firstLiteral.startsWith(HTTPS_SCHEME_PREFIX)) { + scheme = "https"; + hostStartStr = firstLiteral.substring(HTTPS_SCHEME_PREFIX.length()); + } else if (firstLiteral.startsWith(HTTP_SCHEME_PREFIX)) { + scheme = "http"; + hostStartStr = firstLiteral.substring(HTTP_SCHEME_PREFIX.length()); + } else { + return AnalysisResult.notPreParseable(); + } + + // Step 2: Scan expressions to find port and path boundaries + int port = -1; + StringBuilder encodedPathBuilder = new StringBuilder(); + List hostExpressions = new ArrayList<>(); + boolean foundPath = false; + + // Add the remainder of the first literal (after "scheme://") as the start of host + if (!hostStartStr.isEmpty()) { + ScanResult scanResult = scanLiteral(hostStartStr, hostExpressions, encodedPathBuilder); + if (!scanResult.preParseable) { + return AnalysisResult.notPreParseable(); + } + if (scanResult.foundPort) { + port = scanResult.port; + } + foundPath = scanResult.foundPath; + } + + // Process remaining expressions + for (int i = 1; i < expressions.size(); i++) { + RuleExpression expr = expressions.get(i); + + if (foundPath) { + // Once we're in the path, any non-literal means path is dynamic → not pre-parseable + if (!(expr instanceof LiteralStringExpression)) { + return AnalysisResult.notPreParseable(); + } + encodedPathBuilder.append(((LiteralStringExpression) expr).value()); + continue; + } + + if (expr instanceof LiteralStringExpression) { + String literal = ((LiteralStringExpression) expr).value(); + ScanResult scanResult = scanLiteral(literal, hostExpressions, encodedPathBuilder); + if (!scanResult.preParseable) { + return AnalysisResult.notPreParseable(); + } + if (scanResult.foundPort) { + port = scanResult.port; + } + foundPath = scanResult.foundPath; + } else { + // Non-literal expression (variable reference, function call, etc.) + // This is part of the host as long as we haven't hit a path + hostExpressions.add(expr); + } + } + + // Build the host expression from collected parts + RuleExpression hostExpr = buildHostExpression(hostExpressions); + String encodedPath = encodedPathBuilder.toString(); + + return new AnalysisResult(true, scheme, hostExpr, port, encodedPath); + } + + /** + * Scan a literal string segment for port (:{digits}) and path (/) boundaries. + * Adds host portions to hostExpressions and path portions to encodedPathBuilder. + * + * @return a ScanResult indicating what was found + */ + private static ScanResult scanLiteral(String literal, + List hostExpressions, + StringBuilder encodedPathBuilder) { + int slashIdx = literal.indexOf('/'); + int portColonIdx = findPortColon(literal); + + if (slashIdx >= 0 && (portColonIdx < 0 || slashIdx < portColonIdx)) { + // Path found before any port in this literal + String hostPart = literal.substring(0, slashIdx); + if (!hostPart.isEmpty()) { + hostExpressions.add(new LiteralStringExpression(hostPart)); + } + encodedPathBuilder.append(literal.substring(slashIdx)); + return ScanResult.pathFound(); + } else if (portColonIdx >= 0) { + // Port found + String hostPart = literal.substring(0, portColonIdx); + if (!hostPart.isEmpty()) { + hostExpressions.add(new LiteralStringExpression(hostPart)); + } + String portAndRest = literal.substring(portColonIdx + 1); + int restSlashIdx = portAndRest.indexOf('/'); + int port; + if (restSlashIdx >= 0) { + try { + port = Integer.parseInt(portAndRest.substring(0, restSlashIdx)); + } catch (NumberFormatException e) { + return ScanResult.notPreParseable(); + } + encodedPathBuilder.append(portAndRest.substring(restSlashIdx)); + return ScanResult.portAndPathFound(port); + } else { + try { + port = Integer.parseInt(portAndRest); + } catch (NumberFormatException e) { + return ScanResult.notPreParseable(); + } + return ScanResult.portFound(port); + } + } else { + // No port or path in this literal — it's part of the host + hostExpressions.add(new LiteralStringExpression(literal)); + return ScanResult.hostContinue(); + } + } + + /** + * Find the index of a port colon in a literal string. A port colon is a ':' followed by + * one or more digits (optionally followed by '/' or end of string). + * + * @return the index of the colon, or -1 if no port pattern is found + */ + private static int findPortColon(String literal) { + int idx = literal.indexOf(':'); + while (idx >= 0) { + // Check if what follows the colon is all digits (until '/' or end) + int digitStart = idx + 1; + if (digitStart < literal.length() && isDigit(literal.charAt(digitStart))) { + int digitEnd = digitStart; + while (digitEnd < literal.length() && isDigit(literal.charAt(digitEnd))) { + digitEnd++; + } + // Valid port if we consumed at least one digit and hit end or '/' + if (digitEnd == literal.length() || literal.charAt(digitEnd) == '/') { + return idx; + } + } + idx = literal.indexOf(':', idx + 1); + } + return -1; + } + + private static boolean isDigit(char c) { + return c >= '0' && c <= '9'; + } + + /** + * Build a single RuleExpression representing the host from a list of host component expressions. + */ + private static RuleExpression buildHostExpression(List hostExpressions) { + if (hostExpressions.isEmpty()) { + return new LiteralStringExpression(""); + } + if (hostExpressions.size() == 1) { + return hostExpressions.get(0); + } + StringConcatExpression.Builder builder = StringConcatExpression.builder(); + for (RuleExpression expr : hostExpressions) { + builder.addExpression(expr); + } + return builder.build(); + } + + /** + * Internal result type for the literal scanning step. + */ + private static final class ScanResult { + private static final ScanResult NOT_PRE_PARSEABLE = new ScanResult(false, false, false, -1); + private static final ScanResult HOST_CONTINUE = new ScanResult(true, false, false, -1); + + private final boolean preParseable; + private final boolean foundPort; + private final boolean foundPath; + private final int port; + + private ScanResult(boolean preParseable, boolean foundPort, boolean foundPath, int port) { + this.preParseable = preParseable; + this.foundPort = foundPort; + this.foundPath = foundPath; + this.port = port; + } + + static ScanResult notPreParseable() { + return NOT_PRE_PARSEABLE; + } + + static ScanResult hostContinue() { + return HOST_CONTINUE; + } + + static ScanResult pathFound() { + return new ScanResult(true, false, true, -1); + } + + static ScanResult portFound(int port) { + return new ScanResult(true, true, false, port); + } + + static ScanResult portAndPathFound(int port) { + return new ScanResult(true, true, true, port); + } + } + + /** + * Result of analyzing a URL expression. + */ + static final class AnalysisResult { + private static final AnalysisResult NOT_PRE_PARSEABLE = new AnalysisResult(false, null, null, -1, null); + + private final boolean preParseable; + private final String scheme; + private final RuleExpression hostExpr; + private final int port; + private final String encodedPath; + + private AnalysisResult(boolean preParseable, String scheme, RuleExpression hostExpr, + int port, String encodedPath) { + this.preParseable = preParseable; + this.scheme = scheme; + this.hostExpr = hostExpr; + this.port = port; + this.encodedPath = encodedPath; + } + + static AnalysisResult notPreParseable() { + return NOT_PRE_PARSEABLE; + } + + boolean isPreParseable() { + return preParseable; + } + + String scheme() { + return scheme; + } + + RuleExpression hostExpr() { + return hostExpr; + } + + int port() { + return port; + } + + String encodedPath() { + return encodedPath; + } + } +} diff --git a/codegen/src/test/java/software/amazon/awssdk/codegen/poet/rules2/EndpointUrlCodegenAnalyzerTest.java b/codegen/src/test/java/software/amazon/awssdk/codegen/poet/rules2/EndpointUrlCodegenAnalyzerTest.java new file mode 100644 index 000000000000..d14b4344d62c --- /dev/null +++ b/codegen/src/test/java/software/amazon/awssdk/codegen/poet/rules2/EndpointUrlCodegenAnalyzerTest.java @@ -0,0 +1,184 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.codegen.poet.rules2; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link EndpointUrlCodegenAnalyzer}. + * + *

Validates: Requirements 10.1, 10.2, 10.3, 10.4, 10.5

+ * + *

Property 12: Static component extraction correctness — verify that common URL patterns + * (scheme + host concat, no port, no path) are classified as pre-parseable with correct components.

+ */ +class EndpointUrlCodegenAnalyzerTest { + + private static VariableReferenceExpression varRef(String name) { + return new VariableReferenceExpression(name); + } + + private static LiteralStringExpression literal(String value) { + return new LiteralStringExpression(value); + } + + /** + * Simulates: "https://query." + region + "." + partitionResult.dnsSuffix() + */ + @Test + void analyze_withSimpleHostConcat_shouldBePreParseable() { + StringConcatExpression urlExpr = StringConcatExpression.builder() + .addExpression(literal("https://query.")) + .addExpression(varRef("region")) + .addExpression(literal(".")) + .addExpression(varRef("dnsSuffix")) + .build(); + + EndpointUrlCodegenAnalyzer.AnalysisResult result = EndpointUrlCodegenAnalyzer.analyze(urlExpr); + + assertThat(result.isPreParseable()).isTrue(); + assertThat(result.scheme()).isEqualTo("https"); + assertThat(result.port()).isEqualTo(-1); + assertThat(result.encodedPath()).isEqualTo(""); + assertThat(result.hostExpr()).isNotNull(); + } + + /** + * Simulates: "https://runtime.sagemaker." + region + "." + dnsSuffix + ":8443" + */ + @Test + void analyze_withPort_shouldBePreParseableWithCorrectPort() { + StringConcatExpression urlExpr = StringConcatExpression.builder() + .addExpression(literal("https://runtime.sagemaker.")) + .addExpression(varRef("region")) + .addExpression(literal(".")) + .addExpression(varRef("dnsSuffix")) + .addExpression(literal(":8443")) + .build(); + + EndpointUrlCodegenAnalyzer.AnalysisResult result = EndpointUrlCodegenAnalyzer.analyze(urlExpr); + + assertThat(result.isPreParseable()).isTrue(); + assertThat(result.scheme()).isEqualTo("https"); + assertThat(result.port()).isEqualTo(8443); + assertThat(result.encodedPath()).isEqualTo(""); + assertThat(result.hostExpr()).isNotNull(); + } + + /** + * Simulates: "https://places.geo." + region + "." + dnsSuffix + "/v2" + */ + @Test + void analyze_withPath_shouldBePreParseableWithCorrectPath() { + StringConcatExpression urlExpr = StringConcatExpression.builder() + .addExpression(literal("https://places.geo.")) + .addExpression(varRef("region")) + .addExpression(literal(".")) + .addExpression(varRef("dnsSuffix")) + .addExpression(literal("/v2")) + .build(); + + EndpointUrlCodegenAnalyzer.AnalysisResult result = EndpointUrlCodegenAnalyzer.analyze(urlExpr); + + assertThat(result.isPreParseable()).isTrue(); + assertThat(result.scheme()).isEqualTo("https"); + assertThat(result.port()).isEqualTo(-1); + assertThat(result.encodedPath()).isEqualTo("/v2"); + assertThat(result.hostExpr()).isNotNull(); + } + + /** + * Simulates: "https://service." + region + ":8443/v2" + */ + @Test + void analyze_withPortAndPath_shouldBePreParseableWithBoth() { + StringConcatExpression urlExpr = StringConcatExpression.builder() + .addExpression(literal("https://service.")) + .addExpression(varRef("region")) + .addExpression(literal(":8443/v2")) + .build(); + + EndpointUrlCodegenAnalyzer.AnalysisResult result = EndpointUrlCodegenAnalyzer.analyze(urlExpr); + + assertThat(result.isPreParseable()).isTrue(); + assertThat(result.scheme()).isEqualTo("https"); + assertThat(result.port()).isEqualTo(8443); + assertThat(result.encodedPath()).isEqualTo("/v2"); + assertThat(result.hostExpr()).isNotNull(); + } + + /** + * Simulates: "https://s3." + region + "." + dnsSuffix + "/" + uriEncodedBucket + */ + @Test + void analyze_withRuntimeVariableInPath_shouldNotBePreParseable() { + StringConcatExpression urlExpr = StringConcatExpression.builder() + .addExpression(literal("https://s3.")) + .addExpression(varRef("region")) + .addExpression(literal(".")) + .addExpression(varRef("dnsSuffix")) + .addExpression(literal("/")) + .addExpression(varRef("uriEncodedBucket")) + .build(); + + EndpointUrlCodegenAnalyzer.AnalysisResult result = EndpointUrlCodegenAnalyzer.analyze(urlExpr); + + assertThat(result.isPreParseable()).isFalse(); + } + + @Test + void analyze_withFunctionCallExpression_shouldNotBePreParseable() { + FunctionCallExpression urlExpr = FunctionCallExpression.builder() + .name("getUrl") + .addArgument(varRef("endpoint")) + .build(); + + EndpointUrlCodegenAnalyzer.AnalysisResult result = EndpointUrlCodegenAnalyzer.analyze(urlExpr); + + assertThat(result.isPreParseable()).isFalse(); + } + + /** + * Simulates: region + ".amazonaws.com" (first expression is a variable, not a scheme literal) + */ + @Test + void analyze_withFirstExpressionNotSchemeLiteral_shouldNotBePreParseable() { + StringConcatExpression urlExpr = StringConcatExpression.builder() + .addExpression(varRef("region")) + .addExpression(literal(".amazonaws.com")) + .build(); + + EndpointUrlCodegenAnalyzer.AnalysisResult result = EndpointUrlCodegenAnalyzer.analyze(urlExpr); + + assertThat(result.isPreParseable()).isFalse(); + } + + @Test + void analyze_withFullyStaticLiteralUrl_shouldBePreParseableWithCorrectComponents() { + LiteralStringExpression urlExpr = literal("https://example.com"); + + EndpointUrlCodegenAnalyzer.AnalysisResult result = EndpointUrlCodegenAnalyzer.analyze(urlExpr); + + assertThat(result.isPreParseable()).isTrue(); + assertThat(result.scheme()).isEqualTo("https"); + assertThat(result.port()).isEqualTo(-1); + assertThat(result.encodedPath()).isEqualTo(""); + assertThat(result.hostExpr()).isInstanceOf(LiteralStringExpression.class); + assertThat(((LiteralStringExpression) result.hostExpr()).value()).isEqualTo("example.com"); + } +} diff --git a/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules2/endpoint-provider-class.java b/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules2/endpoint-provider-class.java index 2edf6b89329c..d01b466c69f1 100644 --- a/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules2/endpoint-provider-class.java +++ b/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules2/endpoint-provider-class.java @@ -66,8 +66,9 @@ private static RuleResult endpointRule1(QueryEndpointParams params, String regio if (parsedArn != null) { return RuleResult.endpoint(Endpoint .builder() - .endpointUrl(EndpointUrl.fromString("https://" + params.endpointId() + ".query." - + partitionResult.dualStackDnsSuffix())) + .endpointUrl( + EndpointUrl.fromComponents("https", + params.endpointId() + ".query." + partitionResult.dualStackDnsSuffix(), -1, "")) .putAttribute( AwsEndpointAttribute.AUTH_SCHEMES, Arrays.asList(SigV4aAuthScheme.builder().signingName("query") @@ -87,7 +88,9 @@ private static RuleResult endpointRule2(QueryEndpointParams params, RulePartitio if (params.useFipsEndpoint() == null && params.useDualStackEndpoint() != null && params.useDualStackEndpoint()) { return RuleResult.endpoint(Endpoint .builder() - .endpointUrl(EndpointUrl.fromString("https://" + params.endpointId() + ".query." + partitionResult.dualStackDnsSuffix())) + .endpointUrl( + EndpointUrl.fromComponents("https", params.endpointId() + ".query." + partitionResult.dualStackDnsSuffix(), + -1, "")) .putAttribute( AwsEndpointAttribute.AUTH_SCHEMES, Arrays.asList(SigV4aAuthScheme.builder().signingName("query") @@ -95,7 +98,7 @@ private static RuleResult endpointRule2(QueryEndpointParams params, RulePartitio } return RuleResult.endpoint(Endpoint .builder() - .endpointUrl(EndpointUrl.fromString("https://" + params.endpointId() + ".query." + partitionResult.dnsSuffix())) + .endpointUrl(EndpointUrl.fromComponents("https", params.endpointId() + ".query." + partitionResult.dnsSuffix(), -1, "")) .putAttribute( AwsEndpointAttribute.AUTH_SCHEMES, Arrays.asList(SigV4aAuthScheme.builder().signingName("query").signingRegionSet(Arrays.asList("*")) @@ -109,7 +112,7 @@ private static RuleResult endpointRule6(QueryEndpointParams params, String regio if (params.useFipsEndpoint() != null && params.useFipsEndpoint() && params.useDualStackEndpoint() == null) { return RuleResult.endpoint(Endpoint .builder() - .endpointUrl(EndpointUrl.fromString("https://query-fips." + region + "." + partitionResult.dnsSuffix())) + .endpointUrl(EndpointUrl.fromComponents("https", "query-fips." + region + "." + partitionResult.dnsSuffix(), -1, "")) .putAttribute( AwsEndpointAttribute.AUTH_SCHEMES, Arrays.asList(SigV4aAuthScheme.builder().signingName("query") @@ -118,7 +121,8 @@ private static RuleResult endpointRule6(QueryEndpointParams params, String regio if (params.useDualStackEndpoint() != null && params.useDualStackEndpoint() && params.useFipsEndpoint() == null) { return RuleResult.endpoint(Endpoint .builder() - .endpointUrl(EndpointUrl.fromString("https://query." + region + "." + partitionResult.dualStackDnsSuffix())) + .endpointUrl( + EndpointUrl.fromComponents("https", "query." + region + "." + partitionResult.dualStackDnsSuffix(), -1, "")) .putAttribute( AwsEndpointAttribute.AUTH_SCHEMES, Arrays.asList(SigV4aAuthScheme.builder().signingName("query") @@ -129,14 +133,16 @@ private static RuleResult endpointRule6(QueryEndpointParams params, String regio && params.useFipsEndpoint()) { return RuleResult.endpoint(Endpoint .builder() - .endpointUrl(EndpointUrl.fromString("https://query-fips." + region + "." + partitionResult.dualStackDnsSuffix())) + .endpointUrl( + EndpointUrl.fromComponents("https", "query-fips." + region + "." + partitionResult.dualStackDnsSuffix(), -1, + "")) .putAttribute( AwsEndpointAttribute.AUTH_SCHEMES, Arrays.asList(SigV4aAuthScheme.builder().signingName("query") .signingRegionSet(Arrays.asList("*")).build())).build()); } return RuleResult.endpoint(Endpoint.builder() - .endpointUrl(EndpointUrl.fromString("https://query." + region + "." + partitionResult.dnsSuffix())).build()); + .endpointUrl(EndpointUrl.fromComponents("https", "query." + region + "." + partitionResult.dnsSuffix(), -1, "")).build()); } return RuleResult.carryOn(); } diff --git a/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules2/endpoint-provider-know-prop-override-class.java b/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules2/endpoint-provider-know-prop-override-class.java index 2edf6b89329c..d01b466c69f1 100644 --- a/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules2/endpoint-provider-know-prop-override-class.java +++ b/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules2/endpoint-provider-know-prop-override-class.java @@ -66,8 +66,9 @@ private static RuleResult endpointRule1(QueryEndpointParams params, String regio if (parsedArn != null) { return RuleResult.endpoint(Endpoint .builder() - .endpointUrl(EndpointUrl.fromString("https://" + params.endpointId() + ".query." - + partitionResult.dualStackDnsSuffix())) + .endpointUrl( + EndpointUrl.fromComponents("https", + params.endpointId() + ".query." + partitionResult.dualStackDnsSuffix(), -1, "")) .putAttribute( AwsEndpointAttribute.AUTH_SCHEMES, Arrays.asList(SigV4aAuthScheme.builder().signingName("query") @@ -87,7 +88,9 @@ private static RuleResult endpointRule2(QueryEndpointParams params, RulePartitio if (params.useFipsEndpoint() == null && params.useDualStackEndpoint() != null && params.useDualStackEndpoint()) { return RuleResult.endpoint(Endpoint .builder() - .endpointUrl(EndpointUrl.fromString("https://" + params.endpointId() + ".query." + partitionResult.dualStackDnsSuffix())) + .endpointUrl( + EndpointUrl.fromComponents("https", params.endpointId() + ".query." + partitionResult.dualStackDnsSuffix(), + -1, "")) .putAttribute( AwsEndpointAttribute.AUTH_SCHEMES, Arrays.asList(SigV4aAuthScheme.builder().signingName("query") @@ -95,7 +98,7 @@ private static RuleResult endpointRule2(QueryEndpointParams params, RulePartitio } return RuleResult.endpoint(Endpoint .builder() - .endpointUrl(EndpointUrl.fromString("https://" + params.endpointId() + ".query." + partitionResult.dnsSuffix())) + .endpointUrl(EndpointUrl.fromComponents("https", params.endpointId() + ".query." + partitionResult.dnsSuffix(), -1, "")) .putAttribute( AwsEndpointAttribute.AUTH_SCHEMES, Arrays.asList(SigV4aAuthScheme.builder().signingName("query").signingRegionSet(Arrays.asList("*")) @@ -109,7 +112,7 @@ private static RuleResult endpointRule6(QueryEndpointParams params, String regio if (params.useFipsEndpoint() != null && params.useFipsEndpoint() && params.useDualStackEndpoint() == null) { return RuleResult.endpoint(Endpoint .builder() - .endpointUrl(EndpointUrl.fromString("https://query-fips." + region + "." + partitionResult.dnsSuffix())) + .endpointUrl(EndpointUrl.fromComponents("https", "query-fips." + region + "." + partitionResult.dnsSuffix(), -1, "")) .putAttribute( AwsEndpointAttribute.AUTH_SCHEMES, Arrays.asList(SigV4aAuthScheme.builder().signingName("query") @@ -118,7 +121,8 @@ private static RuleResult endpointRule6(QueryEndpointParams params, String regio if (params.useDualStackEndpoint() != null && params.useDualStackEndpoint() && params.useFipsEndpoint() == null) { return RuleResult.endpoint(Endpoint .builder() - .endpointUrl(EndpointUrl.fromString("https://query." + region + "." + partitionResult.dualStackDnsSuffix())) + .endpointUrl( + EndpointUrl.fromComponents("https", "query." + region + "." + partitionResult.dualStackDnsSuffix(), -1, "")) .putAttribute( AwsEndpointAttribute.AUTH_SCHEMES, Arrays.asList(SigV4aAuthScheme.builder().signingName("query") @@ -129,14 +133,16 @@ private static RuleResult endpointRule6(QueryEndpointParams params, String regio && params.useFipsEndpoint()) { return RuleResult.endpoint(Endpoint .builder() - .endpointUrl(EndpointUrl.fromString("https://query-fips." + region + "." + partitionResult.dualStackDnsSuffix())) + .endpointUrl( + EndpointUrl.fromComponents("https", "query-fips." + region + "." + partitionResult.dualStackDnsSuffix(), -1, + "")) .putAttribute( AwsEndpointAttribute.AUTH_SCHEMES, Arrays.asList(SigV4aAuthScheme.builder().signingName("query") .signingRegionSet(Arrays.asList("*")).build())).build()); } return RuleResult.endpoint(Endpoint.builder() - .endpointUrl(EndpointUrl.fromString("https://query." + region + "." + partitionResult.dnsSuffix())).build()); + .endpointUrl(EndpointUrl.fromComponents("https", "query." + region + "." + partitionResult.dnsSuffix(), -1, "")).build()); } return RuleResult.carryOn(); } diff --git a/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules2/endpoint-provider-metric-values-class.java b/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules2/endpoint-provider-metric-values-class.java index a086d3466d4e..cadeb0b86890 100644 --- a/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules2/endpoint-provider-metric-values-class.java +++ b/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules2/endpoint-provider-metric-values-class.java @@ -69,20 +69,22 @@ private static RuleResult endpointRule2(QueryEndpointParams params, RulePartitio } if (params.useFipsEndpoint() == null && params.useDualStackEndpoint() != null && params.useDualStackEndpoint()) { return RuleResult.endpoint(Endpoint - .builder() - .endpointUrl(EndpointUrl.fromString("https://" + params.endpointId() + ".query." + partitionResult.dualStackDnsSuffix())) - .putAttribute( - AwsEndpointAttribute.AUTH_SCHEMES, - Arrays.asList(SigV4aAuthScheme.builder().signingName("query") - .signingRegionSet(Arrays.asList("*")).build())).build()); + .builder() + .endpointUrl( + EndpointUrl.fromComponents("https", params.endpointId() + ".query." + partitionResult.dualStackDnsSuffix(), + -1, "")) + .putAttribute( + AwsEndpointAttribute.AUTH_SCHEMES, + Arrays.asList(SigV4aAuthScheme.builder().signingName("query") + .signingRegionSet(Arrays.asList("*")).build())).build()); } return RuleResult.endpoint(Endpoint - .builder() - .endpointUrl(EndpointUrl.fromString("https://" + params.endpointId() + ".query." + partitionResult.dnsSuffix())) - .putAttribute( - AwsEndpointAttribute.AUTH_SCHEMES, - Arrays.asList(SigV4aAuthScheme.builder().signingName("query").signingRegionSet(Arrays.asList("*")) - .build())).putAttribute(AwsEndpointAttribute.METRIC_VALUES, Arrays.asList("1", "2")).build()); + .builder() + .endpointUrl(EndpointUrl.fromComponents("https", params.endpointId() + ".query." + partitionResult.dnsSuffix(), -1, "")) + .putAttribute( + AwsEndpointAttribute.AUTH_SCHEMES, + Arrays.asList(SigV4aAuthScheme.builder().signingName("query").signingRegionSet(Arrays.asList("*")) + .build())).putAttribute(AwsEndpointAttribute.METRIC_VALUES, Arrays.asList("1", "2")).build()); } return RuleResult.carryOn(); } @@ -91,35 +93,38 @@ private static RuleResult endpointRule6(QueryEndpointParams params, String regio if (RulesFunctions.isValidHostLabel(region, false)) { if (params.useFipsEndpoint() != null && params.useFipsEndpoint() && params.useDualStackEndpoint() == null) { return RuleResult.endpoint(Endpoint - .builder() - .endpointUrl(EndpointUrl.fromString("https://query-fips." + region + "." + partitionResult.dnsSuffix())) - .putAttribute( - AwsEndpointAttribute.AUTH_SCHEMES, - Arrays.asList(SigV4aAuthScheme.builder().signingName("query") - .signingRegionSet(Arrays.asList("*")).build())).build()); + .builder() + .endpointUrl(EndpointUrl.fromComponents("https", "query-fips." + region + "." + partitionResult.dnsSuffix(), -1, "")) + .putAttribute( + AwsEndpointAttribute.AUTH_SCHEMES, + Arrays.asList(SigV4aAuthScheme.builder().signingName("query") + .signingRegionSet(Arrays.asList("*")).build())).build()); } if (params.useDualStackEndpoint() != null && params.useDualStackEndpoint() && params.useFipsEndpoint() == null) { return RuleResult.endpoint(Endpoint - .builder() - .endpointUrl(EndpointUrl.fromString("https://query." + region + "." + partitionResult.dualStackDnsSuffix())) - .putAttribute( - AwsEndpointAttribute.AUTH_SCHEMES, - Arrays.asList(SigV4aAuthScheme.builder().signingName("query") - .signingRegionSet(Arrays.asList("*")).build(), - SigV4AuthScheme.builder().signingName("query").signingRegion(region).build())).build()); + .builder() + .endpointUrl( + EndpointUrl.fromComponents("https", "query." + region + "." + partitionResult.dualStackDnsSuffix(), -1, "")) + .putAttribute( + AwsEndpointAttribute.AUTH_SCHEMES, + Arrays.asList(SigV4aAuthScheme.builder().signingName("query") + .signingRegionSet(Arrays.asList("*")).build(), + SigV4AuthScheme.builder().signingName("query").signingRegion(region).build())).build()); } if (params.useDualStackEndpoint() != null && params.useFipsEndpoint() != null && params.useDualStackEndpoint() - && params.useFipsEndpoint()) { + && params.useFipsEndpoint()) { return RuleResult.endpoint(Endpoint - .builder() - .endpointUrl(EndpointUrl.fromString("https://query-fips." + region + "." + partitionResult.dualStackDnsSuffix())) - .putAttribute( - AwsEndpointAttribute.AUTH_SCHEMES, - Arrays.asList(SigV4aAuthScheme.builder().signingName("query") - .signingRegionSet(Arrays.asList("*")).build())).build()); + .builder() + .endpointUrl( + EndpointUrl.fromComponents("https", "query-fips." + region + "." + partitionResult.dualStackDnsSuffix(), -1, + "")) + .putAttribute( + AwsEndpointAttribute.AUTH_SCHEMES, + Arrays.asList(SigV4aAuthScheme.builder().signingName("query") + .signingRegionSet(Arrays.asList("*")).build())).build()); } return RuleResult.endpoint(Endpoint.builder() - .endpointUrl(EndpointUrl.fromString("https://query." + region + "." + partitionResult.dnsSuffix())).build()); + .endpointUrl(EndpointUrl.fromComponents("https", "query." + region + "." + partitionResult.dnsSuffix(), -1, "")).build()); } return RuleResult.carryOn(); } diff --git a/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules2/endpoint-provider-uri-cache-class.java b/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules2/endpoint-provider-uri-cache-class.java index 2edf6b89329c..d01b466c69f1 100644 --- a/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules2/endpoint-provider-uri-cache-class.java +++ b/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules2/endpoint-provider-uri-cache-class.java @@ -66,8 +66,9 @@ private static RuleResult endpointRule1(QueryEndpointParams params, String regio if (parsedArn != null) { return RuleResult.endpoint(Endpoint .builder() - .endpointUrl(EndpointUrl.fromString("https://" + params.endpointId() + ".query." - + partitionResult.dualStackDnsSuffix())) + .endpointUrl( + EndpointUrl.fromComponents("https", + params.endpointId() + ".query." + partitionResult.dualStackDnsSuffix(), -1, "")) .putAttribute( AwsEndpointAttribute.AUTH_SCHEMES, Arrays.asList(SigV4aAuthScheme.builder().signingName("query") @@ -87,7 +88,9 @@ private static RuleResult endpointRule2(QueryEndpointParams params, RulePartitio if (params.useFipsEndpoint() == null && params.useDualStackEndpoint() != null && params.useDualStackEndpoint()) { return RuleResult.endpoint(Endpoint .builder() - .endpointUrl(EndpointUrl.fromString("https://" + params.endpointId() + ".query." + partitionResult.dualStackDnsSuffix())) + .endpointUrl( + EndpointUrl.fromComponents("https", params.endpointId() + ".query." + partitionResult.dualStackDnsSuffix(), + -1, "")) .putAttribute( AwsEndpointAttribute.AUTH_SCHEMES, Arrays.asList(SigV4aAuthScheme.builder().signingName("query") @@ -95,7 +98,7 @@ private static RuleResult endpointRule2(QueryEndpointParams params, RulePartitio } return RuleResult.endpoint(Endpoint .builder() - .endpointUrl(EndpointUrl.fromString("https://" + params.endpointId() + ".query." + partitionResult.dnsSuffix())) + .endpointUrl(EndpointUrl.fromComponents("https", params.endpointId() + ".query." + partitionResult.dnsSuffix(), -1, "")) .putAttribute( AwsEndpointAttribute.AUTH_SCHEMES, Arrays.asList(SigV4aAuthScheme.builder().signingName("query").signingRegionSet(Arrays.asList("*")) @@ -109,7 +112,7 @@ private static RuleResult endpointRule6(QueryEndpointParams params, String regio if (params.useFipsEndpoint() != null && params.useFipsEndpoint() && params.useDualStackEndpoint() == null) { return RuleResult.endpoint(Endpoint .builder() - .endpointUrl(EndpointUrl.fromString("https://query-fips." + region + "." + partitionResult.dnsSuffix())) + .endpointUrl(EndpointUrl.fromComponents("https", "query-fips." + region + "." + partitionResult.dnsSuffix(), -1, "")) .putAttribute( AwsEndpointAttribute.AUTH_SCHEMES, Arrays.asList(SigV4aAuthScheme.builder().signingName("query") @@ -118,7 +121,8 @@ private static RuleResult endpointRule6(QueryEndpointParams params, String regio if (params.useDualStackEndpoint() != null && params.useDualStackEndpoint() && params.useFipsEndpoint() == null) { return RuleResult.endpoint(Endpoint .builder() - .endpointUrl(EndpointUrl.fromString("https://query." + region + "." + partitionResult.dualStackDnsSuffix())) + .endpointUrl( + EndpointUrl.fromComponents("https", "query." + region + "." + partitionResult.dualStackDnsSuffix(), -1, "")) .putAttribute( AwsEndpointAttribute.AUTH_SCHEMES, Arrays.asList(SigV4aAuthScheme.builder().signingName("query") @@ -129,14 +133,16 @@ private static RuleResult endpointRule6(QueryEndpointParams params, String regio && params.useFipsEndpoint()) { return RuleResult.endpoint(Endpoint .builder() - .endpointUrl(EndpointUrl.fromString("https://query-fips." + region + "." + partitionResult.dualStackDnsSuffix())) + .endpointUrl( + EndpointUrl.fromComponents("https", "query-fips." + region + "." + partitionResult.dualStackDnsSuffix(), -1, + "")) .putAttribute( AwsEndpointAttribute.AUTH_SCHEMES, Arrays.asList(SigV4aAuthScheme.builder().signingName("query") .signingRegionSet(Arrays.asList("*")).build())).build()); } return RuleResult.endpoint(Endpoint.builder() - .endpointUrl(EndpointUrl.fromString("https://query." + region + "." + partitionResult.dnsSuffix())).build()); + .endpointUrl(EndpointUrl.fromComponents("https", "query." + region + "." + partitionResult.dnsSuffix(), -1, "")).build()); } return RuleResult.carryOn(); } From f34c5f0e14a18366e5af6e2384dfa47a7c6fe70a Mon Sep 17 00:00:00 2001 From: Alex Woods Date: Mon, 20 Jul 2026 12:13:19 -0700 Subject: [PATCH 2/5] Refactor, replace analyzer with emitter --- .../poet/rules2/CodeGeneratorVisitor.java | 21 +- ...lyzer.java => EndpointUrlCodeEmitter.java} | 266 ++++++++------- .../rules2/EndpointUrlCodeEmitterTest.java | 307 ++++++++++++++++++ .../EndpointUrlCodegenAnalyzerTest.java | 184 ----------- 4 files changed, 437 insertions(+), 341 deletions(-) rename codegen/src/main/java/software/amazon/awssdk/codegen/poet/rules2/{EndpointUrlCodegenAnalyzer.java => EndpointUrlCodeEmitter.java} (51%) create mode 100644 codegen/src/test/java/software/amazon/awssdk/codegen/poet/rules2/EndpointUrlCodeEmitterTest.java delete mode 100644 codegen/src/test/java/software/amazon/awssdk/codegen/poet/rules2/EndpointUrlCodegenAnalyzerTest.java diff --git a/codegen/src/main/java/software/amazon/awssdk/codegen/poet/rules2/CodeGeneratorVisitor.java b/codegen/src/main/java/software/amazon/awssdk/codegen/poet/rules2/CodeGeneratorVisitor.java index fb1e6611e9a4..34d6a64a3154 100644 --- a/codegen/src/main/java/software/amazon/awssdk/codegen/poet/rules2/CodeGeneratorVisitor.java +++ b/codegen/src/main/java/software/amazon/awssdk/codegen/poet/rules2/CodeGeneratorVisitor.java @@ -28,7 +28,6 @@ import software.amazon.awssdk.awscore.endpoints.authscheme.SigV4aAuthScheme; import software.amazon.awssdk.codegen.model.config.customization.KeyTypePair; import software.amazon.awssdk.endpoints.Endpoint; -import software.amazon.awssdk.endpoints.EndpointUrl; public class CodeGeneratorVisitor extends WalkRuleExpressionVisitor { private static final Logger log = LoggerFactory.getLogger(CodeGeneratorVisitor.class); @@ -329,23 +328,9 @@ private String callParams(String ruleId) { @Override public Void visitEndpointExpression(EndpointExpression e) { builder.add("return $T.endpoint(", typeMirror.rulesResult().type()); - - EndpointUrlCodegenAnalyzer.AnalysisResult analysis = - EndpointUrlCodegenAnalyzer.analyze(e.url()); - - if (analysis.isPreParseable()) { - // Emit EndpointUrl.fromComponents(scheme, hostExpr, port, path) — no runtime parsing - builder.add("$T.builder().endpointUrl($T.fromComponents($S, ", - Endpoint.class, EndpointUrl.class, analysis.scheme()); - analysis.hostExpr().accept(this); - builder.add(", $L, $S))", analysis.port(), analysis.encodedPath()); - } else { - // Fallback: emit EndpointUrl.fromString(fullUrl) — lightweight runtime parsing - builder.add("$T.builder().endpointUrl($T.fromString(", Endpoint.class, EndpointUrl.class); - e.url().accept(this); - builder.add("))"); - } - + builder.add("$T.builder().endpointUrl(", Endpoint.class); + EndpointUrlCodeEmitter.emit(e.url(), builder, this); + builder.add(")"); e.headers().accept(this); e.properties().accept(this); builder.add(".build()"); diff --git a/codegen/src/main/java/software/amazon/awssdk/codegen/poet/rules2/EndpointUrlCodegenAnalyzer.java b/codegen/src/main/java/software/amazon/awssdk/codegen/poet/rules2/EndpointUrlCodeEmitter.java similarity index 51% rename from codegen/src/main/java/software/amazon/awssdk/codegen/poet/rules2/EndpointUrlCodegenAnalyzer.java rename to codegen/src/main/java/software/amazon/awssdk/codegen/poet/rules2/EndpointUrlCodeEmitter.java index 59c020d7ef7e..72873b830ce4 100644 --- a/codegen/src/main/java/software/amazon/awssdk/codegen/poet/rules2/EndpointUrlCodegenAnalyzer.java +++ b/codegen/src/main/java/software/amazon/awssdk/codegen/poet/rules2/EndpointUrlCodeEmitter.java @@ -15,45 +15,69 @@ package software.amazon.awssdk.codegen.poet.rules2; +import com.squareup.javapoet.CodeBlock; import java.util.ArrayList; import java.util.List; +import software.amazon.awssdk.endpoints.EndpointUrl; /** - * Analyzes an endpoint URL expression at codegen time to determine if its components - * (scheme, host, port, path) can be statically decomposed, enabling the use of - * {@code EndpointUrl.fromComponents()} instead of {@code EndpointUrl.fromString()} in generated code. + * Emits the optimal {@code EndpointUrl} construction code for a URL expression. + * + *

This class encapsulates both the analysis of the URL expression structure (determining whether + * it can be statically decomposed) and the code emission. When the URL starts with a literal scheme + * prefix ({@code https://} or {@code http://}), the components (scheme, host, port, path) can be + * identified at codegen time and emitted as {@code EndpointUrl.fromComponents()} — eliminating all + * runtime string parsing. Otherwise, falls back to {@code EndpointUrl.fromString()}. + * + *

The endpoint URL spec guarantees that URLs contain only scheme, host, optional port, and optional + * base path (no query or fragment). This means any expression appearing after the path boundary is + * still part of the path component, allowing dynamic paths to be pre-parsed as well. */ -final class EndpointUrlCodegenAnalyzer { +final class EndpointUrlCodeEmitter { private static final String HTTPS_SCHEME_PREFIX = "https://"; private static final String HTTP_SCHEME_PREFIX = "http://"; - private EndpointUrlCodegenAnalyzer() { + private EndpointUrlCodeEmitter() { } /** - * Analyze the URL expression from an EndpointExpression. + * Emit the optimal EndpointUrl construction code for the given URL expression. * - * @param urlExpr the URL expression (typically a StringConcatExpression or LiteralStringExpression) - * @return an AnalysisResult indicating whether pre-parsing is possible and the decomposed components + *

Writes to {@code builder} either: + *

    + *
  • {@code EndpointUrl.fromComponents(scheme, hostExpr, port, pathExpr)} when the URL + * can be statically decomposed, or
  • + *
  • {@code EndpointUrl.fromString(urlExpr)} as a fallback.
  • + *
+ * + * @param urlExpr the URL expression from the endpoint rule + * @param builder the CodeBlock builder to emit into + * @param codegenVisitor the parent code generator, used to emit sub-expressions */ - static AnalysisResult analyze(RuleExpression urlExpr) { + static void emit(RuleExpression urlExpr, CodeBlock.Builder builder, CodeGeneratorVisitor codegenVisitor) { if (urlExpr instanceof LiteralStringExpression) { - return analyzeStaticUrl(((LiteralStringExpression) urlExpr).value()); + emitStaticUrl(((LiteralStringExpression) urlExpr).value(), builder); + return; } - if (!(urlExpr instanceof StringConcatExpression)) { - return AnalysisResult.notPreParseable(); + if (urlExpr instanceof StringConcatExpression) { + if (tryEmitFromComponents((StringConcatExpression) urlExpr, builder, codegenVisitor)) { + return; + } } - return analyzeStringConcat((StringConcatExpression) urlExpr); + // Fallback: emit EndpointUrl.fromString(fullUrl) + emitFromString(urlExpr, builder, codegenVisitor); } /** - * Analyze a fully static URL string (from a LiteralStringExpression). + * Emit a fully static URL as EndpointUrl.fromComponents() with all literal arguments. */ - private static AnalysisResult analyzeStaticUrl(String url) { + private static void emitStaticUrl(String url, CodeBlock.Builder builder) { int schemeEnd = url.indexOf("://"); if (schemeEnd < 0) { - return AnalysisResult.notPreParseable(); + // No scheme found — shouldn't happen per spec, but fall back safely + builder.add("$T.fromString($S)", EndpointUrl.class, url); + return; } String scheme = url.substring(0, schemeEnd); @@ -78,30 +102,33 @@ private static AnalysisResult analyzeStaticUrl(String url) { try { port = Integer.parseInt(authority.substring(colonPos + 1)); } catch (NumberFormatException e) { - // Not a valid port — treat the whole authority as the host host = authority; } } else { host = authority; } - RuleExpression hostExpr = new LiteralStringExpression(host); - return new AnalysisResult(true, scheme, hostExpr, port, encodedPath); + builder.add("$T.fromComponents($S, $S, $L, $S)", EndpointUrl.class, scheme, host, port, encodedPath); } /** - * Analyze a StringConcatExpression URL template. + * Attempt to emit EndpointUrl.fromComponents() for a StringConcatExpression URL template. + * + *

Returns {@code true} if successful (the URL could be decomposed), {@code false} if the + * caller should fall back to fromString(). */ - private static AnalysisResult analyzeStringConcat(StringConcatExpression concatExpr) { + private static boolean tryEmitFromComponents(StringConcatExpression concatExpr, + CodeBlock.Builder builder, + CodeGeneratorVisitor codegenVisitor) { List expressions = concatExpr.expressions(); if (expressions.isEmpty()) { - return AnalysisResult.notPreParseable(); + return false; } - // Step 1: The first expression must be a literal starting with a scheme + // The first expression must be a literal starting with a scheme RuleExpression firstExpr = expressions.get(0); if (!(firstExpr instanceof LiteralStringExpression)) { - return AnalysisResult.notPreParseable(); + return false; } String firstLiteral = ((LiteralStringExpression) firstExpr).value(); @@ -114,20 +141,20 @@ private static AnalysisResult analyzeStringConcat(StringConcatExpression concatE scheme = "http"; hostStartStr = firstLiteral.substring(HTTP_SCHEME_PREFIX.length()); } else { - return AnalysisResult.notPreParseable(); + return false; } - // Step 2: Scan expressions to find port and path boundaries + // Scan expressions to find port and path boundaries, collecting host and path parts int port = -1; - StringBuilder encodedPathBuilder = new StringBuilder(); - List hostExpressions = new ArrayList<>(); + List hostParts = new ArrayList<>(); + List pathParts = new ArrayList<>(); boolean foundPath = false; - // Add the remainder of the first literal (after "scheme://") as the start of host + // Process the remainder of the first literal (after "scheme://") if (!hostStartStr.isEmpty()) { - ScanResult scanResult = scanLiteral(hostStartStr, hostExpressions, encodedPathBuilder); - if (!scanResult.preParseable) { - return AnalysisResult.notPreParseable(); + ScanResult scanResult = scanLiteral(hostStartStr, hostParts, pathParts); + if (!scanResult.parseable) { + return false; } if (scanResult.foundPort) { port = scanResult.port; @@ -140,47 +167,83 @@ private static AnalysisResult analyzeStringConcat(StringConcatExpression concatE RuleExpression expr = expressions.get(i); if (foundPath) { - // Once we're in the path, any non-literal means path is dynamic → not pre-parseable - if (!(expr instanceof LiteralStringExpression)) { - return AnalysisResult.notPreParseable(); + // Everything after the path boundary is path — literals and expressions alike + if (expr instanceof LiteralStringExpression) { + pathParts.add(expr); + } else { + pathParts.add(expr); } - encodedPathBuilder.append(((LiteralStringExpression) expr).value()); continue; } if (expr instanceof LiteralStringExpression) { String literal = ((LiteralStringExpression) expr).value(); - ScanResult scanResult = scanLiteral(literal, hostExpressions, encodedPathBuilder); - if (!scanResult.preParseable) { - return AnalysisResult.notPreParseable(); + ScanResult scanResult = scanLiteral(literal, hostParts, pathParts); + if (!scanResult.parseable) { + return false; } if (scanResult.foundPort) { port = scanResult.port; } foundPath = scanResult.foundPath; } else { - // Non-literal expression (variable reference, function call, etc.) - // This is part of the host as long as we haven't hit a path - hostExpressions.add(expr); + // Non-literal expression — part of the host since we haven't hit a path + hostParts.add(expr); } } - // Build the host expression from collected parts - RuleExpression hostExpr = buildHostExpression(hostExpressions); - String encodedPath = encodedPathBuilder.toString(); + // Emit: EndpointUrl.fromComponents(scheme, hostExpr, port, pathExpr) + builder.add("$T.fromComponents($S, ", EndpointUrl.class, scheme); + emitConcatExpression(hostParts, builder, codegenVisitor); + builder.add(", $L, ", port); + emitConcatExpression(pathParts, builder, codegenVisitor); + builder.add(")"); + return true; + } - return new AnalysisResult(true, scheme, hostExpr, port, encodedPath); + /** + * Emit EndpointUrl.fromString(urlExpr) as the fallback. + */ + private static void emitFromString(RuleExpression urlExpr, CodeBlock.Builder builder, + CodeGeneratorVisitor codegenVisitor) { + builder.add("$T.fromString(", EndpointUrl.class); + urlExpr.accept(codegenVisitor); + builder.add(")"); } /** - * Scan a literal string segment for port (:{digits}) and path (/) boundaries. - * Adds host portions to hostExpressions and path portions to encodedPathBuilder. + * Emit a list of expression parts as a concatenated expression. * - * @return a ScanResult indicating what was found + *

If the list is empty, emits an empty string literal. If it contains a single literal string, + * emits that literal directly. Otherwise, emits the parts joined with {@code +}. + */ + private static void emitConcatExpression(List parts, CodeBlock.Builder builder, + CodeGeneratorVisitor codegenVisitor) { + if (parts.isEmpty()) { + builder.add("$S", ""); + return; + } + if (parts.size() == 1 && parts.get(0) instanceof LiteralStringExpression) { + builder.add("$S", ((LiteralStringExpression) parts.get(0)).value()); + return; + } + boolean isFirst = true; + for (RuleExpression part : parts) { + if (!isFirst) { + builder.add(" + "); + } + part.accept(codegenVisitor); + isFirst = false; + } + } + + /** + * Scan a literal string segment for port (:{digits}) and path (/) boundaries. + * Adds host portions to hostParts and path portions to pathParts. */ private static ScanResult scanLiteral(String literal, - List hostExpressions, - StringBuilder encodedPathBuilder) { + List hostParts, + List pathParts) { int slashIdx = literal.indexOf('/'); int portColonIdx = findPortColon(literal); @@ -188,15 +251,15 @@ private static ScanResult scanLiteral(String literal, // Path found before any port in this literal String hostPart = literal.substring(0, slashIdx); if (!hostPart.isEmpty()) { - hostExpressions.add(new LiteralStringExpression(hostPart)); + hostParts.add(new LiteralStringExpression(hostPart)); } - encodedPathBuilder.append(literal.substring(slashIdx)); + pathParts.add(new LiteralStringExpression(literal.substring(slashIdx))); return ScanResult.pathFound(); } else if (portColonIdx >= 0) { // Port found String hostPart = literal.substring(0, portColonIdx); if (!hostPart.isEmpty()) { - hostExpressions.add(new LiteralStringExpression(hostPart)); + hostParts.add(new LiteralStringExpression(hostPart)); } String portAndRest = literal.substring(portColonIdx + 1); int restSlashIdx = portAndRest.indexOf('/'); @@ -205,42 +268,38 @@ private static ScanResult scanLiteral(String literal, try { port = Integer.parseInt(portAndRest.substring(0, restSlashIdx)); } catch (NumberFormatException e) { - return ScanResult.notPreParseable(); + return ScanResult.NOT_PARSEABLE; } - encodedPathBuilder.append(portAndRest.substring(restSlashIdx)); + pathParts.add(new LiteralStringExpression(portAndRest.substring(restSlashIdx))); return ScanResult.portAndPathFound(port); } else { try { port = Integer.parseInt(portAndRest); } catch (NumberFormatException e) { - return ScanResult.notPreParseable(); + return ScanResult.NOT_PARSEABLE; } return ScanResult.portFound(port); } } else { - // No port or path in this literal — it's part of the host - hostExpressions.add(new LiteralStringExpression(literal)); - return ScanResult.hostContinue(); + // No port or path — this literal is part of the host + hostParts.add(new LiteralStringExpression(literal)); + return ScanResult.HOST_CONTINUE; } } /** * Find the index of a port colon in a literal string. A port colon is a ':' followed by * one or more digits (optionally followed by '/' or end of string). - * - * @return the index of the colon, or -1 if no port pattern is found */ private static int findPortColon(String literal) { int idx = literal.indexOf(':'); while (idx >= 0) { - // Check if what follows the colon is all digits (until '/' or end) int digitStart = idx + 1; if (digitStart < literal.length() && isDigit(literal.charAt(digitStart))) { int digitEnd = digitStart; while (digitEnd < literal.length() && isDigit(literal.charAt(digitEnd))) { digitEnd++; } - // Valid port if we consumed at least one digit and hit end or '/' if (digitEnd == literal.length() || literal.charAt(digitEnd) == '/') { return idx; } @@ -254,50 +313,25 @@ private static boolean isDigit(char c) { return c >= '0' && c <= '9'; } - /** - * Build a single RuleExpression representing the host from a list of host component expressions. - */ - private static RuleExpression buildHostExpression(List hostExpressions) { - if (hostExpressions.isEmpty()) { - return new LiteralStringExpression(""); - } - if (hostExpressions.size() == 1) { - return hostExpressions.get(0); - } - StringConcatExpression.Builder builder = StringConcatExpression.builder(); - for (RuleExpression expr : hostExpressions) { - builder.addExpression(expr); - } - return builder.build(); - } - /** * Internal result type for the literal scanning step. */ private static final class ScanResult { - private static final ScanResult NOT_PRE_PARSEABLE = new ScanResult(false, false, false, -1); + private static final ScanResult NOT_PARSEABLE = new ScanResult(false, false, false, -1); private static final ScanResult HOST_CONTINUE = new ScanResult(true, false, false, -1); - private final boolean preParseable; + private final boolean parseable; private final boolean foundPort; private final boolean foundPath; private final int port; - private ScanResult(boolean preParseable, boolean foundPort, boolean foundPath, int port) { - this.preParseable = preParseable; + private ScanResult(boolean parseable, boolean foundPort, boolean foundPath, int port) { + this.parseable = parseable; this.foundPort = foundPort; this.foundPath = foundPath; this.port = port; } - static ScanResult notPreParseable() { - return NOT_PRE_PARSEABLE; - } - - static ScanResult hostContinue() { - return HOST_CONTINUE; - } - static ScanResult pathFound() { return new ScanResult(true, false, true, -1); } @@ -310,50 +344,4 @@ static ScanResult portAndPathFound(int port) { return new ScanResult(true, true, true, port); } } - - /** - * Result of analyzing a URL expression. - */ - static final class AnalysisResult { - private static final AnalysisResult NOT_PRE_PARSEABLE = new AnalysisResult(false, null, null, -1, null); - - private final boolean preParseable; - private final String scheme; - private final RuleExpression hostExpr; - private final int port; - private final String encodedPath; - - private AnalysisResult(boolean preParseable, String scheme, RuleExpression hostExpr, - int port, String encodedPath) { - this.preParseable = preParseable; - this.scheme = scheme; - this.hostExpr = hostExpr; - this.port = port; - this.encodedPath = encodedPath; - } - - static AnalysisResult notPreParseable() { - return NOT_PRE_PARSEABLE; - } - - boolean isPreParseable() { - return preParseable; - } - - String scheme() { - return scheme; - } - - RuleExpression hostExpr() { - return hostExpr; - } - - int port() { - return port; - } - - String encodedPath() { - return encodedPath; - } - } } diff --git a/codegen/src/test/java/software/amazon/awssdk/codegen/poet/rules2/EndpointUrlCodeEmitterTest.java b/codegen/src/test/java/software/amazon/awssdk/codegen/poet/rules2/EndpointUrlCodeEmitterTest.java new file mode 100644 index 000000000000..cddd53142c51 --- /dev/null +++ b/codegen/src/test/java/software/amazon/awssdk/codegen/poet/rules2/EndpointUrlCodeEmitterTest.java @@ -0,0 +1,307 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.codegen.poet.rules2; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.squareup.javapoet.CodeBlock; +import java.util.Collections; +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link EndpointUrlCodeEmitter}. + * + *

Verifies that URL expressions are correctly decomposed into {@code EndpointUrl.fromComponents()} + * calls when possible, and fall back to {@code EndpointUrl.fromString()} otherwise.

+ */ +class EndpointUrlCodeEmitterTest { + + private static VariableReferenceExpression varRef(String name) { + return new VariableReferenceExpression(name); + } + + private static LiteralStringExpression literal(String value) { + return new LiteralStringExpression(value); + } + + private static MemberAccessExpression memberAccess(String source, String name) { + return MemberAccessExpression.builder() + .source(new VariableReferenceExpression(source)) + .name(name) + .build(); + } + + /** + * Creates a minimal CodeGeneratorVisitor suitable for testing expression emission. + * Only the expression visiting methods (literal, varRef, memberAccess, stringConcat) are used. + */ + private static String emitUrl(RuleExpression urlExpr) { + CodeBlock.Builder builder = CodeBlock.builder(); + CodeGeneratorVisitor visitor = new CodeGeneratorVisitor( + new RuleRuntimeTypeMirror("software.amazon.awssdk.test"), + SymbolTable.builder().build(), + Collections.emptyMap(), + Collections.emptyMap(), + builder + ); + EndpointUrlCodeEmitter.emit(urlExpr, builder, visitor); + return builder.build().toString(); + } + + // --- Pre-parseable cases: emit fromComponents --- + + /** + * "https://sts.{Region}.{PartitionResult#dnsSuffix}" — typical service URL. + */ + @Test + void emit_simpleHostConcat_emitsFromComponents() { + StringConcatExpression urlExpr = StringConcatExpression.builder() + .addExpression(literal("https://sts.")) + .addExpression(varRef("region")) + .addExpression(literal(".")) + .addExpression(memberAccess("partitionResult", "dnsSuffix")) + .build(); + + String code = emitUrl(urlExpr); + + assertThat(code).contains("EndpointUrl.fromComponents("); + assertThat(code).contains("\"https\""); + assertThat(code).contains("\"sts.\""); + assertThat(code).contains("region"); + assertThat(code).contains("partitionResult.dnsSuffix()"); + assertThat(code).contains("-1"); + assertThat(code).contains("\"\""); // empty path + assertThat(code).doesNotContain("fromString"); + } + + /** + * "https://runtime.sagemaker.{Region}.{dnsSuffix}:8443" — URL with port. + */ + @Test + void emit_withPort_emitsFromComponentsWithPort() { + StringConcatExpression urlExpr = StringConcatExpression.builder() + .addExpression(literal("https://runtime.sagemaker.")) + .addExpression(varRef("region")) + .addExpression(literal(".")) + .addExpression(varRef("dnsSuffix")) + .addExpression(literal(":8443")) + .build(); + + String code = emitUrl(urlExpr); + + assertThat(code).contains("EndpointUrl.fromComponents("); + assertThat(code).contains("\"https\""); + assertThat(code).contains("8443"); + assertThat(code).contains("\"\""); // empty path + assertThat(code).doesNotContain("fromString"); + } + + /** + * "https://places.geo.{Region}.{dnsSuffix}/v2" — URL with static path. + */ + @Test + void emit_withStaticPath_emitsFromComponentsWithPath() { + StringConcatExpression urlExpr = StringConcatExpression.builder() + .addExpression(literal("https://places.geo.")) + .addExpression(varRef("region")) + .addExpression(literal(".")) + .addExpression(varRef("dnsSuffix")) + .addExpression(literal("/v2")) + .build(); + + String code = emitUrl(urlExpr); + + assertThat(code).contains("EndpointUrl.fromComponents("); + assertThat(code).contains("\"https\""); + assertThat(code).contains("-1"); + assertThat(code).contains("\"/v2\""); // static path emitted as literal + assertThat(code).doesNotContain("fromString"); + } + + /** + * "https://service.{Region}:8443/v2" — URL with port and path in same literal. + */ + @Test + void emit_withPortAndPath_emitsFromComponentsWithBoth() { + StringConcatExpression urlExpr = StringConcatExpression.builder() + .addExpression(literal("https://service.")) + .addExpression(varRef("region")) + .addExpression(literal(":8443/v2")) + .build(); + + String code = emitUrl(urlExpr); + + assertThat(code).contains("EndpointUrl.fromComponents("); + assertThat(code).contains("\"https\""); + assertThat(code).contains("8443"); + assertThat(code).contains("\"/v2\""); + assertThat(code).doesNotContain("fromString"); + } + + /** + * "https://s3.{Region}.{dnsSuffix}/{uriEncodedBucket}" — URL with dynamic path variable. + * This should now be pre-parseable (path contains dynamic expression). + */ + @Test + void emit_withDynamicPath_emitsFromComponentsWithPathExpression() { + StringConcatExpression urlExpr = StringConcatExpression.builder() + .addExpression(literal("https://s3.")) + .addExpression(varRef("region")) + .addExpression(literal(".")) + .addExpression(varRef("dnsSuffix")) + .addExpression(literal("/")) + .addExpression(varRef("uriEncodedBucket")) + .build(); + + String code = emitUrl(urlExpr); + + assertThat(code).contains("EndpointUrl.fromComponents("); + assertThat(code).contains("\"https\""); + assertThat(code).contains("region"); + assertThat(code).contains("dnsSuffix"); + // Path is dynamic: "/" + uriEncodedBucket + assertThat(code).contains("\"/\""); + assertThat(code).contains("uriEncodedBucket"); + assertThat(code).doesNotContain("fromString"); + } + + /** + * "https://sts.amazonaws.com" — fully static literal URL. + */ + @Test + void emit_fullyStaticLiteral_emitsFromComponentsAllLiterals() { + LiteralStringExpression urlExpr = literal("https://sts.amazonaws.com"); + + String code = emitUrl(urlExpr); + + assertThat(code).contains("EndpointUrl.fromComponents("); + assertThat(code).contains("\"https\""); + assertThat(code).contains("\"sts.amazonaws.com\""); + assertThat(code).contains("-1"); + assertThat(code).contains("\"\""); + assertThat(code).doesNotContain("fromString"); + } + + /** + * "https://example.com:443/api/v1" — static URL with port and path. + */ + @Test + void emit_fullyStaticWithPortAndPath_emitsFromComponentsAllLiterals() { + LiteralStringExpression urlExpr = literal("https://example.com:443/api/v1"); + + String code = emitUrl(urlExpr); + + assertThat(code).contains("EndpointUrl.fromComponents("); + assertThat(code).contains("\"https\""); + assertThat(code).contains("\"example.com\""); + assertThat(code).contains("443"); + assertThat(code).contains("\"/api/v1\""); + assertThat(code).doesNotContain("fromString"); + } + + // --- Fallback cases: emit fromString --- + + /** + * "{url#scheme}://{Bucket}.{url#authority}{url#path}" — scheme is dynamic. + */ + @Test + void emit_dynamicScheme_emitsFromString() { + StringConcatExpression urlExpr = StringConcatExpression.builder() + .addExpression(memberAccess("url", "scheme")) + .addExpression(literal("://")) + .addExpression(varRef("Bucket")) + .addExpression(literal(".")) + .addExpression(memberAccess("url", "authority")) + .addExpression(memberAccess("url", "path")) + .build(); + + String code = emitUrl(urlExpr); + + assertThat(code).contains("EndpointUrl.fromString("); + assertThat(code).doesNotContain("fromComponents"); + } + + /** + * VariableReferenceExpression (e.g. just a variable) — not a string template or literal. + */ + @Test + void emit_variableReferenceExpression_emitsFromString() { + VariableReferenceExpression urlExpr = varRef("computedUrl"); + + String code = emitUrl(urlExpr); + + assertThat(code).contains("EndpointUrl.fromString("); + assertThat(code).contains("computedUrl"); + assertThat(code).doesNotContain("fromComponents"); + } + + /** + * region + ".amazonaws.com" — first expression is not a scheme literal. + */ + @Test + void emit_firstExpressionNotSchemeLiteral_emitsFromString() { + StringConcatExpression urlExpr = StringConcatExpression.builder() + .addExpression(varRef("region")) + .addExpression(literal(".amazonaws.com")) + .build(); + + String code = emitUrl(urlExpr); + + assertThat(code).contains("EndpointUrl.fromString("); + assertThat(code).doesNotContain("fromComponents"); + } + + // --- Edge cases --- + + /** + * "http://localhost:8080/test" — http scheme (not https). + */ + @Test + void emit_httpScheme_emitsFromComponents() { + LiteralStringExpression urlExpr = literal("http://localhost:8080/test"); + + String code = emitUrl(urlExpr); + + assertThat(code).contains("EndpointUrl.fromComponents("); + assertThat(code).contains("\"http\""); + assertThat(code).contains("\"localhost\""); + assertThat(code).contains("8080"); + assertThat(code).contains("\"/test\""); + } + + /** + * "https://{Bucket}.s3.{Region}.{dnsSuffix}" — host starts with a dynamic expression + * after the scheme prefix (the literal "https://" is followed by nothing before the first varRef). + */ + @Test + void emit_hostStartsWithVariable_emitsFromComponents() { + StringConcatExpression urlExpr = StringConcatExpression.builder() + .addExpression(literal("https://")) + .addExpression(varRef("Bucket")) + .addExpression(literal(".s3.")) + .addExpression(varRef("region")) + .addExpression(literal(".")) + .addExpression(varRef("dnsSuffix")) + .build(); + + String code = emitUrl(urlExpr); + + assertThat(code).contains("EndpointUrl.fromComponents("); + assertThat(code).contains("\"https\""); + assertThat(code).contains("Bucket"); + assertThat(code).doesNotContain("fromString"); + } +} diff --git a/codegen/src/test/java/software/amazon/awssdk/codegen/poet/rules2/EndpointUrlCodegenAnalyzerTest.java b/codegen/src/test/java/software/amazon/awssdk/codegen/poet/rules2/EndpointUrlCodegenAnalyzerTest.java deleted file mode 100644 index d14b4344d62c..000000000000 --- a/codegen/src/test/java/software/amazon/awssdk/codegen/poet/rules2/EndpointUrlCodegenAnalyzerTest.java +++ /dev/null @@ -1,184 +0,0 @@ -/* - * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://aws.amazon.com/apache2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ - -package software.amazon.awssdk.codegen.poet.rules2; - -import static org.assertj.core.api.Assertions.assertThat; - -import org.junit.jupiter.api.Test; - -/** - * Tests for {@link EndpointUrlCodegenAnalyzer}. - * - *

Validates: Requirements 10.1, 10.2, 10.3, 10.4, 10.5

- * - *

Property 12: Static component extraction correctness — verify that common URL patterns - * (scheme + host concat, no port, no path) are classified as pre-parseable with correct components.

- */ -class EndpointUrlCodegenAnalyzerTest { - - private static VariableReferenceExpression varRef(String name) { - return new VariableReferenceExpression(name); - } - - private static LiteralStringExpression literal(String value) { - return new LiteralStringExpression(value); - } - - /** - * Simulates: "https://query." + region + "." + partitionResult.dnsSuffix() - */ - @Test - void analyze_withSimpleHostConcat_shouldBePreParseable() { - StringConcatExpression urlExpr = StringConcatExpression.builder() - .addExpression(literal("https://query.")) - .addExpression(varRef("region")) - .addExpression(literal(".")) - .addExpression(varRef("dnsSuffix")) - .build(); - - EndpointUrlCodegenAnalyzer.AnalysisResult result = EndpointUrlCodegenAnalyzer.analyze(urlExpr); - - assertThat(result.isPreParseable()).isTrue(); - assertThat(result.scheme()).isEqualTo("https"); - assertThat(result.port()).isEqualTo(-1); - assertThat(result.encodedPath()).isEqualTo(""); - assertThat(result.hostExpr()).isNotNull(); - } - - /** - * Simulates: "https://runtime.sagemaker." + region + "." + dnsSuffix + ":8443" - */ - @Test - void analyze_withPort_shouldBePreParseableWithCorrectPort() { - StringConcatExpression urlExpr = StringConcatExpression.builder() - .addExpression(literal("https://runtime.sagemaker.")) - .addExpression(varRef("region")) - .addExpression(literal(".")) - .addExpression(varRef("dnsSuffix")) - .addExpression(literal(":8443")) - .build(); - - EndpointUrlCodegenAnalyzer.AnalysisResult result = EndpointUrlCodegenAnalyzer.analyze(urlExpr); - - assertThat(result.isPreParseable()).isTrue(); - assertThat(result.scheme()).isEqualTo("https"); - assertThat(result.port()).isEqualTo(8443); - assertThat(result.encodedPath()).isEqualTo(""); - assertThat(result.hostExpr()).isNotNull(); - } - - /** - * Simulates: "https://places.geo." + region + "." + dnsSuffix + "/v2" - */ - @Test - void analyze_withPath_shouldBePreParseableWithCorrectPath() { - StringConcatExpression urlExpr = StringConcatExpression.builder() - .addExpression(literal("https://places.geo.")) - .addExpression(varRef("region")) - .addExpression(literal(".")) - .addExpression(varRef("dnsSuffix")) - .addExpression(literal("/v2")) - .build(); - - EndpointUrlCodegenAnalyzer.AnalysisResult result = EndpointUrlCodegenAnalyzer.analyze(urlExpr); - - assertThat(result.isPreParseable()).isTrue(); - assertThat(result.scheme()).isEqualTo("https"); - assertThat(result.port()).isEqualTo(-1); - assertThat(result.encodedPath()).isEqualTo("/v2"); - assertThat(result.hostExpr()).isNotNull(); - } - - /** - * Simulates: "https://service." + region + ":8443/v2" - */ - @Test - void analyze_withPortAndPath_shouldBePreParseableWithBoth() { - StringConcatExpression urlExpr = StringConcatExpression.builder() - .addExpression(literal("https://service.")) - .addExpression(varRef("region")) - .addExpression(literal(":8443/v2")) - .build(); - - EndpointUrlCodegenAnalyzer.AnalysisResult result = EndpointUrlCodegenAnalyzer.analyze(urlExpr); - - assertThat(result.isPreParseable()).isTrue(); - assertThat(result.scheme()).isEqualTo("https"); - assertThat(result.port()).isEqualTo(8443); - assertThat(result.encodedPath()).isEqualTo("/v2"); - assertThat(result.hostExpr()).isNotNull(); - } - - /** - * Simulates: "https://s3." + region + "." + dnsSuffix + "/" + uriEncodedBucket - */ - @Test - void analyze_withRuntimeVariableInPath_shouldNotBePreParseable() { - StringConcatExpression urlExpr = StringConcatExpression.builder() - .addExpression(literal("https://s3.")) - .addExpression(varRef("region")) - .addExpression(literal(".")) - .addExpression(varRef("dnsSuffix")) - .addExpression(literal("/")) - .addExpression(varRef("uriEncodedBucket")) - .build(); - - EndpointUrlCodegenAnalyzer.AnalysisResult result = EndpointUrlCodegenAnalyzer.analyze(urlExpr); - - assertThat(result.isPreParseable()).isFalse(); - } - - @Test - void analyze_withFunctionCallExpression_shouldNotBePreParseable() { - FunctionCallExpression urlExpr = FunctionCallExpression.builder() - .name("getUrl") - .addArgument(varRef("endpoint")) - .build(); - - EndpointUrlCodegenAnalyzer.AnalysisResult result = EndpointUrlCodegenAnalyzer.analyze(urlExpr); - - assertThat(result.isPreParseable()).isFalse(); - } - - /** - * Simulates: region + ".amazonaws.com" (first expression is a variable, not a scheme literal) - */ - @Test - void analyze_withFirstExpressionNotSchemeLiteral_shouldNotBePreParseable() { - StringConcatExpression urlExpr = StringConcatExpression.builder() - .addExpression(varRef("region")) - .addExpression(literal(".amazonaws.com")) - .build(); - - EndpointUrlCodegenAnalyzer.AnalysisResult result = EndpointUrlCodegenAnalyzer.analyze(urlExpr); - - assertThat(result.isPreParseable()).isFalse(); - } - - @Test - void analyze_withFullyStaticLiteralUrl_shouldBePreParseableWithCorrectComponents() { - LiteralStringExpression urlExpr = literal("https://example.com"); - - EndpointUrlCodegenAnalyzer.AnalysisResult result = EndpointUrlCodegenAnalyzer.analyze(urlExpr); - - assertThat(result.isPreParseable()).isTrue(); - assertThat(result.scheme()).isEqualTo("https"); - assertThat(result.port()).isEqualTo(-1); - assertThat(result.encodedPath()).isEqualTo(""); - assertThat(result.hostExpr()).isInstanceOf(LiteralStringExpression.class); - assertThat(((LiteralStringExpression) result.hostExpr()).value()).isEqualTo("example.com"); - } -} From 99b5e9336f4ebd04d027b3d593c444d8300bd2c0 Mon Sep 17 00:00:00 2001 From: Alex Woods Date: Mon, 20 Jul 2026 14:39:11 -0700 Subject: [PATCH 3/5] Cleanup emitter code --- .../poet/rules2/EndpointUrlCodeEmitter.java | 106 ++++++++++-------- 1 file changed, 60 insertions(+), 46 deletions(-) diff --git a/codegen/src/main/java/software/amazon/awssdk/codegen/poet/rules2/EndpointUrlCodeEmitter.java b/codegen/src/main/java/software/amazon/awssdk/codegen/poet/rules2/EndpointUrlCodeEmitter.java index 72873b830ce4..7e5995941a3a 100644 --- a/codegen/src/main/java/software/amazon/awssdk/codegen/poet/rules2/EndpointUrlCodeEmitter.java +++ b/codegen/src/main/java/software/amazon/awssdk/codegen/poet/rules2/EndpointUrlCodeEmitter.java @@ -25,13 +25,13 @@ * *

This class encapsulates both the analysis of the URL expression structure (determining whether * it can be statically decomposed) and the code emission. When the URL starts with a literal scheme - * prefix ({@code https://} or {@code http://}), the components (scheme, host, port, path) can be - * identified at codegen time and emitted as {@code EndpointUrl.fromComponents()} — eliminating all - * runtime string parsing. Otherwise, falls back to {@code EndpointUrl.fromString()}. + * prefix ({@code https://} or {@code http://}) and the components (scheme, host, port, path) can be + * identified at codegen time then we eliminate runtime parsing and use {@code EndpointUrl.fromComponents()}. + * Otherwise, falls back to {@code EndpointUrl.fromString()} for runtime parsing. * *

The endpoint URL spec guarantees that URLs contain only scheme, host, optional port, and optional - * base path (no query or fragment). This means any expression appearing after the path boundary is - * still part of the path component, allowing dynamic paths to be pre-parsed as well. + * base path (no query or fragment). Dynamic (template based) resolution are only supported in the host + * and path segments in pre-parsing. Otherwise, we fall back to runtime parsing. */ final class EndpointUrlCodeEmitter { @@ -57,22 +57,21 @@ private EndpointUrlCodeEmitter() { */ static void emit(RuleExpression urlExpr, CodeBlock.Builder builder, CodeGeneratorVisitor codegenVisitor) { if (urlExpr instanceof LiteralStringExpression) { - emitStaticUrl(((LiteralStringExpression) urlExpr).value(), builder); + emitFromLiteralString(((LiteralStringExpression) urlExpr).value(), builder); return; } if (urlExpr instanceof StringConcatExpression) { - if (tryEmitFromComponents((StringConcatExpression) urlExpr, builder, codegenVisitor)) { - return; - } + emitFromStringConcat((StringConcatExpression) urlExpr, builder, codegenVisitor); + return; } - // Fallback: emit EndpointUrl.fromString(fullUrl) - emitFromString(urlExpr, builder, codegenVisitor); + // Expression type we can't decompose (e.g. variable reference, function call) + emitRuntimeParse(urlExpr, builder, codegenVisitor); } /** * Emit a fully static URL as EndpointUrl.fromComponents() with all literal arguments. */ - private static void emitStaticUrl(String url, CodeBlock.Builder builder) { + private static void emitFromLiteralString(String url, CodeBlock.Builder builder) { int schemeEnd = url.indexOf("://"); if (schemeEnd < 0) { // No scheme found — shouldn't happen per spec, but fall back safely @@ -112,23 +111,45 @@ private static void emitStaticUrl(String url, CodeBlock.Builder builder) { } /** - * Attempt to emit EndpointUrl.fromComponents() for a StringConcatExpression URL template. + * Emit code for a StringConcatExpression URL template. If the URL can be statically decomposed + * (starts with a literal scheme prefix), emits {@code EndpointUrl.fromComponents()}. Otherwise, + * falls back to {@code EndpointUrl.fromString()} for runtime parsing. + * + *

StringConcatExpressions are created by our codegen pre-parser: {@link ExpressionParser}. + * It splits on "{...}" boundaries, producing alternating literal strings and variable/member-access references. The + * result is a StringConcatExpression whose expressions() list interleaves: + *

    + *
  • LiteralStringExpression — static text fragments
  • + *
  • VariableReferenceExpression — "{Region}" eg a variable reference to "Region"
  • + *
  • MemberAccessExpression — "{PartitionResult#dnsSuffix}" → member access on variable "PartitionResult"
  • + *
+ * + *

Typical service endpoints follow something like: "https://sts.{Region}.{PartitionResult#dnsSuffix}" which + * parses to something like: + * {@snippet : + * StringConcatExpression([ + * LiteralStringExpression("https://sts."), + * VariableReferenceExpression("Region"), + * LiteralStringExpression("."), + * MemberAccessExpression(source=VarRef("PartitionResult"), name="dnsSuffix") + * ]) + * } * - *

Returns {@code true} if successful (the URL could be decomposed), {@code false} if the - * caller should fall back to fromString(). */ - private static boolean tryEmitFromComponents(StringConcatExpression concatExpr, - CodeBlock.Builder builder, - CodeGeneratorVisitor codegenVisitor) { + private static void emitFromStringConcat(StringConcatExpression concatExpr, + CodeBlock.Builder builder, + CodeGeneratorVisitor codegenVisitor) { List expressions = concatExpr.expressions(); if (expressions.isEmpty()) { - return false; + emitRuntimeParse(concatExpr, builder, codegenVisitor); + return; } - // The first expression must be a literal starting with a scheme + // The first expression must be a literal starting with a scheme to preparse RuleExpression firstExpr = expressions.get(0); if (!(firstExpr instanceof LiteralStringExpression)) { - return false; + emitRuntimeParse(concatExpr, builder, codegenVisitor); + return; } String firstLiteral = ((LiteralStringExpression) firstExpr).value(); @@ -141,7 +162,8 @@ private static boolean tryEmitFromComponents(StringConcatExpression concatExpr, scheme = "http"; hostStartStr = firstLiteral.substring(HTTP_SCHEME_PREFIX.length()); } else { - return false; + emitRuntimeParse(concatExpr, builder, codegenVisitor); + return; } // Scan expressions to find port and path boundaries, collecting host and path parts @@ -154,7 +176,8 @@ private static boolean tryEmitFromComponents(StringConcatExpression concatExpr, if (!hostStartStr.isEmpty()) { ScanResult scanResult = scanLiteral(hostStartStr, hostParts, pathParts); if (!scanResult.parseable) { - return false; + emitRuntimeParse(concatExpr, builder, codegenVisitor); + return; } if (scanResult.foundPort) { port = scanResult.port; @@ -168,11 +191,7 @@ private static boolean tryEmitFromComponents(StringConcatExpression concatExpr, if (foundPath) { // Everything after the path boundary is path — literals and expressions alike - if (expr instanceof LiteralStringExpression) { - pathParts.add(expr); - } else { - pathParts.add(expr); - } + pathParts.add(expr); continue; } @@ -180,7 +199,8 @@ private static boolean tryEmitFromComponents(StringConcatExpression concatExpr, String literal = ((LiteralStringExpression) expr).value(); ScanResult scanResult = scanLiteral(literal, hostParts, pathParts); if (!scanResult.parseable) { - return false; + emitRuntimeParse(concatExpr, builder, codegenVisitor); + return; } if (scanResult.foundPort) { port = scanResult.port; @@ -198,14 +218,13 @@ private static boolean tryEmitFromComponents(StringConcatExpression concatExpr, builder.add(", $L, ", port); emitConcatExpression(pathParts, builder, codegenVisitor); builder.add(")"); - return true; } /** - * Emit EndpointUrl.fromString(urlExpr) as the fallback. + * Emit EndpointUrl.fromString(urlExpr) for runtime parsing when static decomposition isn't possible. */ - private static void emitFromString(RuleExpression urlExpr, CodeBlock.Builder builder, - CodeGeneratorVisitor codegenVisitor) { + private static void emitRuntimeParse(RuleExpression urlExpr, CodeBlock.Builder builder, + CodeGeneratorVisitor codegenVisitor) { builder.add("$T.fromString(", EndpointUrl.class); urlExpr.accept(codegenVisitor); builder.add(")"); @@ -214,8 +233,9 @@ private static void emitFromString(RuleExpression urlExpr, CodeBlock.Builder bui /** * Emit a list of expression parts as a concatenated expression. * - *

If the list is empty, emits an empty string literal. If it contains a single literal string, - * emits that literal directly. Otherwise, emits the parts joined with {@code +}. + *

If the list is empty, emits an empty string literal. Otherwise, wraps them in a + * {@link StringConcatExpression} and delegates to the code generator's existing concat + * emission logic. */ private static void emitConcatExpression(List parts, CodeBlock.Builder builder, CodeGeneratorVisitor codegenVisitor) { @@ -223,18 +243,11 @@ private static void emitConcatExpression(List parts, CodeBlock.B builder.add("$S", ""); return; } - if (parts.size() == 1 && parts.get(0) instanceof LiteralStringExpression) { - builder.add("$S", ((LiteralStringExpression) parts.get(0)).value()); - return; - } - boolean isFirst = true; + StringConcatExpression.Builder concatBuilder = StringConcatExpression.builder(); for (RuleExpression part : parts) { - if (!isFirst) { - builder.add(" + "); - } - part.accept(codegenVisitor); - isFirst = false; + concatBuilder.addExpression(part); } + concatBuilder.build().accept(codegenVisitor); } /** @@ -255,7 +268,8 @@ private static ScanResult scanLiteral(String literal, } pathParts.add(new LiteralStringExpression(literal.substring(slashIdx))); return ScanResult.pathFound(); - } else if (portColonIdx >= 0) { + } + if (portColonIdx >= 0) { // Port found String hostPart = literal.substring(0, portColonIdx); if (!hostPart.isEmpty()) { From 0e1bd31933e4fa6800f6c45a4feca91bb18da727 Mon Sep 17 00:00:00 2001 From: Alex Woods Date: Tue, 21 Jul 2026 10:21:20 -0700 Subject: [PATCH 4/5] PR cleanups --- .../codegen/poet/rules2/EndpointUrlCodeEmitter.java | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/codegen/src/main/java/software/amazon/awssdk/codegen/poet/rules2/EndpointUrlCodeEmitter.java b/codegen/src/main/java/software/amazon/awssdk/codegen/poet/rules2/EndpointUrlCodeEmitter.java index 7e5995941a3a..957c0a72e933 100644 --- a/codegen/src/main/java/software/amazon/awssdk/codegen/poet/rules2/EndpointUrlCodeEmitter.java +++ b/codegen/src/main/java/software/amazon/awssdk/codegen/poet/rules2/EndpointUrlCodeEmitter.java @@ -97,11 +97,13 @@ private static void emitFromLiteralString(String url, CodeBlock.Builder builder) String host; int colonPos = authority.lastIndexOf(':'); if (colonPos >= 0) { - host = authority.substring(0, colonPos); try { port = Integer.parseInt(authority.substring(colonPos + 1)); + host = authority.substring(0, colonPos); } catch (NumberFormatException e) { - host = authority; + // Not a valid port — can't reliably decompose, fall back to runtime parsing + builder.add("$T.fromString($S)", EndpointUrl.class, url); + return; } } else { host = authority; @@ -252,7 +254,10 @@ private static void emitConcatExpression(List parts, CodeBlock.B /** * Scan a literal string segment for port (:{digits}) and path (/) boundaries. - * Adds host portions to hostParts and path portions to pathParts. + * + *

This method mutates {@code hostParts} and {@code pathParts} as a side effect, appending + * the relevant portions of the literal to each list. On a {@code NOT_PARSEABLE} result, the + * lists may have been partially modified — callers must not reuse the lists after a failure. */ private static ScanResult scanLiteral(String literal, List hostParts, From 0717b1989e4bf7df4c4194de676583254a9575c0 Mon Sep 17 00:00:00 2001 From: Alex Woods Date: Tue, 21 Jul 2026 12:26:42 -0700 Subject: [PATCH 5/5] Add conformance tests --- .../rules2/EndpointUrlCodeEmitterTest.java | 27 ++++ .../amazon/awssdk/endpoints/EndpointUrl.java | 5 + .../internal/EndpointUrlConformanceTest.java | 136 ++++++++++++++++++ 3 files changed, 168 insertions(+) create mode 100644 test/codegen-generated-classes-test/src/test/java/software/amazon/awssdk/services/compiledendpointrules/endpoints/internal/EndpointUrlConformanceTest.java diff --git a/codegen/src/test/java/software/amazon/awssdk/codegen/poet/rules2/EndpointUrlCodeEmitterTest.java b/codegen/src/test/java/software/amazon/awssdk/codegen/poet/rules2/EndpointUrlCodeEmitterTest.java index cddd53142c51..c95c81ac3416 100644 --- a/codegen/src/test/java/software/amazon/awssdk/codegen/poet/rules2/EndpointUrlCodeEmitterTest.java +++ b/codegen/src/test/java/software/amazon/awssdk/codegen/poet/rules2/EndpointUrlCodeEmitterTest.java @@ -304,4 +304,31 @@ void emit_hostStartsWithVariable_emitsFromComponents() { assertThat(code).contains("Bucket"); assertThat(code).doesNotContain("fromString"); } + + /** + * Custom endpoint override: "{url#scheme}://{url#authority}{url#path}" + * + *

This is the standard representation when a user provides a custom endpoint URL via + * client configuration. The endpoint rule decomposes the user-provided URL into its parts + * (scheme, authority, path) and reassembles them with possible modifications (e.g., host prefix). + * Since the scheme is a dynamic expression (not a literal "https://" or "http://"), this + * cannot be pre-parsed and must fall back to runtime parsing. + */ + @Test + void emit_customEndpointOverride_emitsFromString() { + StringConcatExpression urlExpr = StringConcatExpression.builder() + .addExpression(memberAccess("url", "scheme")) + .addExpression(literal("://")) + .addExpression(memberAccess("url", "authority")) + .addExpression(memberAccess("url", "path")) + .build(); + + String code = emitUrl(urlExpr); + + assertThat(code).contains("EndpointUrl.fromString("); + assertThat(code).contains("url.scheme()"); + assertThat(code).contains("url.authority()"); + assertThat(code).contains("url.path()"); + assertThat(code).doesNotContain("fromComponents"); + } } diff --git a/core/endpoints-spi/src/main/java/software/amazon/awssdk/endpoints/EndpointUrl.java b/core/endpoints-spi/src/main/java/software/amazon/awssdk/endpoints/EndpointUrl.java index b6b9594beb45..43a8ac97ea00 100644 --- a/core/endpoints-spi/src/main/java/software/amazon/awssdk/endpoints/EndpointUrl.java +++ b/core/endpoints-spi/src/main/java/software/amazon/awssdk/endpoints/EndpointUrl.java @@ -154,6 +154,11 @@ public static EndpointUrl fromString(String url) { * The {@code rawUrl} field is {@code null} in this case, so {@link #toUri()} reconstructs * the URI from components. No query or fragment is included. * + *

Equivalence contract: For any valid endpoint URL string {@code s} with no query or fragment, + * {@code fromComponents(scheme, host, port, path)} MUST produce an {@code EndpointUrl} that equals + * {@code fromString(scheme + "://" + host + (port >= 0 ? ":" + port : "") + path)}. + * The codegen relies on this equivalence — see {@code EndpointUrlCodeEmitter}. + * * @param scheme the URL scheme (e.g., "https") * @param host the hostname (e.g., "s3.us-east-1.amazonaws.com") * @param port the port number, or -1 if not specified diff --git a/test/codegen-generated-classes-test/src/test/java/software/amazon/awssdk/services/compiledendpointrules/endpoints/internal/EndpointUrlConformanceTest.java b/test/codegen-generated-classes-test/src/test/java/software/amazon/awssdk/services/compiledendpointrules/endpoints/internal/EndpointUrlConformanceTest.java new file mode 100644 index 000000000000..2c4212607a29 --- /dev/null +++ b/test/codegen-generated-classes-test/src/test/java/software/amazon/awssdk/services/compiledendpointrules/endpoints/internal/EndpointUrlConformanceTest.java @@ -0,0 +1,136 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.services.compiledendpointrules.endpoints.internal; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.net.URI; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import software.amazon.awssdk.endpoints.Endpoint; +import software.amazon.awssdk.endpoints.EndpointUrl; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.compiledendpointrules.endpoints.CompiledEndpointRulesEndpointParams; +import software.amazon.awssdk.services.compiledendpointrules.endpoints.CompiledEndpointRulesEndpointProvider; + +/** + * Conformance test verifying that the build-time decomposed EndpointUrl (via {@code EndpointUrl.fromComponents()}) + * produces results equivalent to runtime parsing (via {@code EndpointUrl.fromString()}). + + *

This guards against drift between the build-time ({@code EndpointUrlCodeEmitter}) and runtime + * ({@code EndpointUrl.fromString()}) URL decomposition logic. + */ +class EndpointUrlConformanceTest { + + private static final CompiledEndpointRulesEndpointProvider PROVIDER = + CompiledEndpointRulesEndpointProvider.defaultProvider(); + + /** + * Test cases covering the standard region-based resolution path (uses fromComponents in generated code) + * and the custom endpoint override path (uses fromString in generated code). + */ + static List testCases() { + return Arrays.asList( + // Standard region resolution — exercises fromComponents code path + TestCase.ofRegion("us-east-1", "https://compiledendpointrules.us-east-1.amazonaws.com"), + TestCase.ofRegion("us-west-2", "https://compiledendpointrules.us-west-2.amazonaws.com"), + TestCase.ofRegion("eu-west-1", "https://compiledendpointrules.eu-west-1.amazonaws.com"), + TestCase.ofRegion("ap-southeast-1", "https://compiledendpointrules.ap-southeast-1.amazonaws.com"), + TestCase.ofRegion("cn-north-1", "https://compiledendpointrules.cn-north-1.amazonaws.com.cn"), + TestCase.ofRegion("us-gov-west-1", "https://compiledendpointrules.us-gov-west-1.amazonaws.com"), + + // Custom endpoint override — exercises fromString code path + TestCase.ofEndpoint("us-east-1", "https://custom.example.com", + "https://custom.example.com"), + TestCase.ofEndpoint("us-east-1", "https://custom.example.com:8443", + "https://custom.example.com:8443"), + TestCase.ofEndpoint("us-east-1", "https://custom.example.com/base/path", + "https://custom.example.com/base/path"), + TestCase.ofEndpoint("us-east-1", "http://localhost:4566", + "http://localhost:4566") + ); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("testCases") + void resolvedEndpointUrl_componentsAreConsistentWithUri(TestCase testCase) throws Exception { + CompletableFuture future = PROVIDER.resolveEndpoint(testCase.params()); + Endpoint endpoint = future.get(); + EndpointUrl endpointUrl = endpoint.endpointUrl(); + + // 1. Verify the resolved URL matches what we expect + assertThat(endpointUrl.toUri()).isEqualTo(URI.create(testCase.expectedUrl)); + + // 2. Verify component getters are consistent with toUri() + URI uri = endpointUrl.toUri(); + assertThat(endpointUrl.scheme()).isEqualTo(uri.getScheme()); + assertThat(endpointUrl.host()).isEqualTo(uri.getHost()); + assertThat(endpointUrl.port()).isEqualTo(uri.getPort()); + String expectedPath = uri.getRawPath() != null ? uri.getRawPath() : ""; + assertThat(endpointUrl.encodedPath()).isEqualTo(expectedPath); + + // 3. Verify round-trip: reconstruct URL string from components, re-parse, and compare + String reconstructed = endpointUrl.scheme() + "://" + endpointUrl.host() + + (endpointUrl.port() >= 0 ? ":" + endpointUrl.port() : "") + + endpointUrl.encodedPath(); + EndpointUrl reparsed = EndpointUrl.fromString(reconstructed); + + assertThat(reparsed.scheme()).isEqualTo(endpointUrl.scheme()); + assertThat(reparsed.host()).isEqualTo(endpointUrl.host()); + assertThat(reparsed.port()).isEqualTo(endpointUrl.port()); + assertThat(reparsed.encodedPath()).isEqualTo(endpointUrl.encodedPath()); + assertThat(reparsed).isEqualTo(endpointUrl); + } + + static class TestCase { + private final String description; + private final Region region; + private final String customEndpoint; + private final String expectedUrl; + + TestCase(String description, Region region, String customEndpoint, String expectedUrl) { + this.description = description; + this.region = region; + this.customEndpoint = customEndpoint; + this.expectedUrl = expectedUrl; + } + + static TestCase ofRegion(String regionId, String expectedUrl) { + return new TestCase("region=" + regionId, Region.of(regionId), null, expectedUrl); + } + + static TestCase ofEndpoint(String regionId, String endpoint, String expectedUrl) { + return new TestCase("endpoint=" + endpoint, Region.of(regionId), endpoint, expectedUrl); + } + + CompiledEndpointRulesEndpointParams params() { + CompiledEndpointRulesEndpointParams.Builder builder = CompiledEndpointRulesEndpointParams.builder() + .region(region); + if (customEndpoint != null) { + builder.endpoint(customEndpoint); + } + return builder.build(); + } + + @Override + public String toString() { + return description; + } + } +}