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..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,9 +328,9 @@ 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("))"); + 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/EndpointUrlCodeEmitter.java b/codegen/src/main/java/software/amazon/awssdk/codegen/poet/rules2/EndpointUrlCodeEmitter.java new file mode 100644 index 000000000000..957c0a72e933 --- /dev/null +++ b/codegen/src/main/java/software/amazon/awssdk/codegen/poet/rules2/EndpointUrlCodeEmitter.java @@ -0,0 +1,366 @@ +/* + * 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 com.squareup.javapoet.CodeBlock; +import java.util.ArrayList; +import java.util.List; +import software.amazon.awssdk.endpoints.EndpointUrl; + +/** + * 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://}) 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). 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 { + + private static final String HTTPS_SCHEME_PREFIX = "https://"; + private static final String HTTP_SCHEME_PREFIX = "http://"; + + private EndpointUrlCodeEmitter() { + } + + /** + * Emit the optimal EndpointUrl construction code for the given URL expression. + * + *

Writes to {@code builder} either: + *

+ * + * @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 void emit(RuleExpression urlExpr, CodeBlock.Builder builder, CodeGeneratorVisitor codegenVisitor) { + if (urlExpr instanceof LiteralStringExpression) { + emitFromLiteralString(((LiteralStringExpression) urlExpr).value(), builder); + return; + } + if (urlExpr instanceof StringConcatExpression) { + emitFromStringConcat((StringConcatExpression) urlExpr, builder, codegenVisitor); + return; + } + // 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 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 + builder.add("$T.fromString($S)", EndpointUrl.class, url); + return; + } + + 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) { + try { + port = Integer.parseInt(authority.substring(colonPos + 1)); + host = authority.substring(0, colonPos); + } catch (NumberFormatException e) { + // 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; + } + + builder.add("$T.fromComponents($S, $S, $L, $S)", EndpointUrl.class, scheme, host, port, encodedPath); + } + + /** + * 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: + *

+ * + *

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") + * ]) + * } + * + */ + private static void emitFromStringConcat(StringConcatExpression concatExpr, + CodeBlock.Builder builder, + CodeGeneratorVisitor codegenVisitor) { + List expressions = concatExpr.expressions(); + if (expressions.isEmpty()) { + emitRuntimeParse(concatExpr, builder, codegenVisitor); + return; + } + + // The first expression must be a literal starting with a scheme to preparse + RuleExpression firstExpr = expressions.get(0); + if (!(firstExpr instanceof LiteralStringExpression)) { + emitRuntimeParse(concatExpr, builder, codegenVisitor); + return; + } + + 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 { + emitRuntimeParse(concatExpr, builder, codegenVisitor); + return; + } + + // Scan expressions to find port and path boundaries, collecting host and path parts + int port = -1; + List hostParts = new ArrayList<>(); + List pathParts = new ArrayList<>(); + boolean foundPath = false; + + // Process the remainder of the first literal (after "scheme://") + if (!hostStartStr.isEmpty()) { + ScanResult scanResult = scanLiteral(hostStartStr, hostParts, pathParts); + if (!scanResult.parseable) { + emitRuntimeParse(concatExpr, builder, codegenVisitor); + return; + } + 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) { + // Everything after the path boundary is path — literals and expressions alike + pathParts.add(expr); + continue; + } + + if (expr instanceof LiteralStringExpression) { + String literal = ((LiteralStringExpression) expr).value(); + ScanResult scanResult = scanLiteral(literal, hostParts, pathParts); + if (!scanResult.parseable) { + emitRuntimeParse(concatExpr, builder, codegenVisitor); + return; + } + if (scanResult.foundPort) { + port = scanResult.port; + } + foundPath = scanResult.foundPath; + } else { + // Non-literal expression — part of the host since we haven't hit a path + hostParts.add(expr); + } + } + + // 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(")"); + } + + /** + * Emit EndpointUrl.fromString(urlExpr) for runtime parsing when static decomposition isn't possible. + */ + private static void emitRuntimeParse(RuleExpression urlExpr, CodeBlock.Builder builder, + CodeGeneratorVisitor codegenVisitor) { + builder.add("$T.fromString(", EndpointUrl.class); + urlExpr.accept(codegenVisitor); + builder.add(")"); + } + + /** + * Emit a list of expression parts as a concatenated expression. + * + *

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) { + if (parts.isEmpty()) { + builder.add("$S", ""); + return; + } + StringConcatExpression.Builder concatBuilder = StringConcatExpression.builder(); + for (RuleExpression part : parts) { + concatBuilder.addExpression(part); + } + concatBuilder.build().accept(codegenVisitor); + } + + /** + * Scan a literal string segment for port (:{digits}) and path (/) boundaries. + * + *

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, + List pathParts) { + 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()) { + hostParts.add(new LiteralStringExpression(hostPart)); + } + pathParts.add(new LiteralStringExpression(literal.substring(slashIdx))); + return ScanResult.pathFound(); + } + if (portColonIdx >= 0) { + // Port found + String hostPart = literal.substring(0, portColonIdx); + if (!hostPart.isEmpty()) { + hostParts.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.NOT_PARSEABLE; + } + pathParts.add(new LiteralStringExpression(portAndRest.substring(restSlashIdx))); + return ScanResult.portAndPathFound(port); + } else { + try { + port = Integer.parseInt(portAndRest); + } catch (NumberFormatException e) { + return ScanResult.NOT_PARSEABLE; + } + return ScanResult.portFound(port); + } + } else { + // 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). + */ + private static int findPortColon(String literal) { + int idx = literal.indexOf(':'); + while (idx >= 0) { + int digitStart = idx + 1; + if (digitStart < literal.length() && isDigit(literal.charAt(digitStart))) { + int digitEnd = digitStart; + while (digitEnd < literal.length() && isDigit(literal.charAt(digitEnd))) { + digitEnd++; + } + 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'; + } + + /** + * Internal result type for the literal scanning step. + */ + private static final class ScanResult { + 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 parseable; + private final boolean foundPort; + private final boolean foundPath; + private final int port; + + private ScanResult(boolean parseable, boolean foundPort, boolean foundPath, int port) { + this.parseable = parseable; + this.foundPort = foundPort; + this.foundPath = foundPath; + this.port = port; + } + + 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); + } + } +} 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..c95c81ac3416 --- /dev/null +++ b/codegen/src/test/java/software/amazon/awssdk/codegen/poet/rules2/EndpointUrlCodeEmitterTest.java @@ -0,0 +1,334 @@ +/* + * 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"); + } + + /** + * 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/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(); } 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; + } + } +}