diff --git a/.changeset/selfish-pens-scream.md b/.changeset/selfish-pens-scream.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/selfish-pens-scream.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.gitignore b/.gitignore index 318dc60a888..2e9f855bd0d 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,7 @@ smithy-typescript-integ-tests/yarn.lock # Issue https://github.com/awslabs/smithy-typescript/issues/425 smithy-typescript-codegen/bin/ +smithy-typescript-codegen-test/bin/ smithy-typescript-ssdk-codegen-test-utils/bin/ smithy-typescript-codegen-test/example-weather-customizations/bin/ diff --git a/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/integration/EventStreamGenerator.java b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/integration/EventStreamGenerator.java index 9d80dd5f327..61facae385e 100644 --- a/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/integration/EventStreamGenerator.java +++ b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/integration/EventStreamGenerator.java @@ -493,8 +493,9 @@ private void readEventHeaders(GenerationContext context, StructureShape event) { .filter(member -> member.hasTrait(EventHeaderTrait.class)).collect(Collectors.toList()); for (MemberShape headerMember : headerMembers) { String memberName = headerMember.getMemberName(); - writer.openBlock("if (output.headers[$S] !== undefined) {", "}", memberName, () -> { - writer.write("contents.$1L = output.headers[$1S].value;", memberName); + String varName = context.getStringStore().var(memberName); + writer.openBlock("if (output.headers[$L] !== undefined) {", "}", varName, () -> { + writer.write("contents[$1L] = output.headers[$1L].value;", varName); }); } } diff --git a/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/integration/HttpBindingProtocolGenerator.java b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/integration/HttpBindingProtocolGenerator.java index 2542212d67b..b9536541fac 100644 --- a/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/integration/HttpBindingProtocolGenerator.java +++ b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/integration/HttpBindingProtocolGenerator.java @@ -202,6 +202,10 @@ public void generateSharedComponents(GenerationContext context) { HttpProtocolGeneratorUtils.generateMetadataDeserializer(context, getApplicationProtocol().getResponseType()); HttpProtocolGeneratorUtils.generateCollectBodyString(context); HttpProtocolGeneratorUtils.generateHttpBindingUtils(context); + + writer.write( + context.getStringStore().getIncremental() + ); } @Override @@ -673,7 +677,9 @@ private void generateOperationRequestSerializer( // Get the hostname, path, port, and scheme from client's resolved endpoint. // Then construct the request from them. The client's resolved endpoint can // be default one or supplied by users. - writer.write("const {hostname, protocol = $S, port, path: basePath} = await context.endpoint();", "https"); + + writer.addImport("requestBuilder", "rb", TypeScriptDependency.SMITHY_CORE); + writer.write("const b = rb(input, context);"); writeRequestHeaders(context, operation, bindingIndex); writeResolvedPath(context, operation, bindingIndex, trait); @@ -692,24 +698,17 @@ private void generateOperationRequestSerializer( boolean hasHostPrefix = operation.hasTrait(EndpointTrait.class); if (hasHostPrefix) { HttpProtocolGeneratorUtils.writeHostPrefix(context, operation); + writer.write("b.hn(resolvedHostname);"); } - writer.openBlock("return new $T({", "});", requestType, () -> { - writer.write("protocol,"); - if (hasHostPrefix) { - writer.write("hostname: resolvedHostname,"); - } else { - writer.write("hostname,"); - } - writer.write("port,"); - writer.write("method: $S,", trait.getMethod()); - writer.write("headers,"); - writer.write("path: resolvedPath,"); - if (hasQueryComponents) { - writer.write("query,"); - } - // Always set the body, - writer.write("body,"); - }); + writer.write("b.m($S)", trait.getMethod()); + writer.write(".h(headers)"); + if (hasQueryComponents) { + writer.write(".q(query)"); + } + // Always set the body, + writer.write(".b(body);"); + + writer.write("return b.build();"); }); writer.write(""); @@ -762,8 +761,7 @@ private void writeResolvedPath( : Collections.emptyMap(); // Always write the bound path, but only the actual segments. - writer.write("let resolvedPath = `$L` + $S;", - "${basePath?.endsWith('/') ? basePath.slice(0, -1) : (basePath || '')}", + writer.write("b.bp(\"$L\");", "/" + trait.getUri().getSegments().stream() .filter(segment -> { if (!useEndpointsV2) { @@ -804,7 +802,7 @@ private void writeResolvedPath( // Get the correct label to use. Segment uriLabel = uriLabels.stream().filter(s -> s.getContent().equals(memberName)).findFirst().get(); - writer.write("resolvedPath = __resolvedPath(resolvedPath, input, '$L', $L, '$L', $L)", + writer.write("b.p('$L', $L, '$L', $L)", memberName, labelValueProvider, uriLabel.toString(), @@ -830,7 +828,7 @@ private boolean writeRequestQueryString( writer.openBlock("const query: any = map({", "});", () -> { if (!queryLiterals.isEmpty()) { // Write any query literals present in the uri. - queryLiterals.forEach((k, v) -> writer.write("$S: [, $S],", k, v)); + queryLiterals.forEach((k, v) -> writer.write("[$L]: [, $S],", context.getStringStore().var(k), v)); } // Handle any additional query params bindings. // If query string parameter is also present in httpQuery, it would be overwritten. @@ -879,27 +877,32 @@ private void writeRequestQueryParam( String queryValue = getInputValue( context, binding.getLocation(), - "input." + memberName + memberAssertionComponent, + "input[" + context.getStringStore().var(memberName) + "]" + memberAssertionComponent, binding.getMember(), target ); writer.addImport("expectNonNull", "__expectNonNull", TypeScriptDependency.AWS_SMITHY_CLIENT); - if (Objects.equals("input." + memberName + memberAssertionComponent, queryValue)) { + if ( + Objects.equals( + "input[" + context.getStringStore().var(memberName) + "]" + memberAssertionComponent, + queryValue + ) + ) { String value = isRequired ? "__expectNonNull($L, `" + memberName + "`)" : "$L"; // simple undefined check writer.write( - "$S: [," + value + idempotencyComponent + "],", - binding.getLocationName(), + "[$L]: [," + value + idempotencyComponent + "],", + context.getStringStore().var(binding.getLocationName()), queryValue ); } else { if (isRequired) { // __expectNonNull is immediately invoked and not inside a function. writer.write( - "$S: [__expectNonNull(input.$L, `$L`) != null, () => $L],", - binding.getLocationName(), + "[$L]: [__expectNonNull(input.$L, `$L`) != null, () => $L],", + context.getStringStore().var(binding.getLocationName()), memberName, memberName, queryValue // no idempotency token default for required members @@ -907,8 +910,8 @@ private void writeRequestQueryParam( } else { // undefined check with lazy eval writer.write( - "$S: [() => input.$L !== void 0, () => ($L)$L],", - binding.getLocationName(), + "[$L]: [() => input.$L !== void 0, () => ($L)$L],", + context.getStringStore().var(binding.getLocationName()), memberName, queryValue, idempotencyComponent @@ -976,7 +979,9 @@ private void writeRequestHeaders( } private void writeNormalHeader(GenerationContext context, HttpBinding binding) { - String memberLocation = "input." + context.getSymbolProvider().toMemberName(binding.getMember()); + String memberLocation = "input[" + + context.getStringStore().var(context.getSymbolProvider().toMemberName(binding.getMember())) + + "]"; Shape target = context.getModel().expectShape(binding.getMember().getTarget()); String headerKey = binding.getLocationName().toLowerCase(Locale.US); @@ -996,8 +1001,8 @@ private void writeNormalHeader(GenerationContext context, HttpBinding binding) { } // evaluated value has a function or method call attached headerBuffer.put(headerKey, String.format( - "'%s': [() => isSerializableHeaderValue(%s), () => %s],", - headerKey, + "[%s]: [() => isSerializableHeaderValue(%s), () => %s],", + context.getStringStore().var(headerKey), memberLocation + defaultValue, headerValue + defaultValue )); @@ -1008,8 +1013,8 @@ private void writeNormalHeader(GenerationContext context, HttpBinding binding) { value = headerValue + " || " + s.substring(s.indexOf(": ") + 2, s.length() - 1); } headerBuffer.put(headerKey, String.format( - "'%s': %s,", - headerKey, + "[%s]: %s,", + context.getStringStore().var(headerKey), value )); } @@ -2231,14 +2236,20 @@ private void readNormalHeaders( String memberName = context.getSymbolProvider().toMemberName(binding.getMember()); String headerName = binding.getLocationName().toLowerCase(Locale.US); Shape target = context.getModel().expectShape(binding.getMember().getTarget()); - String headerValue = getOutputValue(context, binding.getLocation(), - outputName + ".headers['" + headerName + "']", binding.getMember(), target); - String checkedValue = outputName + ".headers['" + headerName + "']"; + String headerValue = getOutputValue( + context, binding.getLocation(), + outputName + ".headers[" + context.getStringStore().var(headerName) + "]", + binding.getMember(), target + ); + String checkedValue = outputName + ".headers[" + context.getStringStore().var(headerName) + "]"; if (checkedValue.equals(headerValue)) { - writer.write("$L: [, $L],", memberName, headerValue); + writer.write("[$L]: [, $L],", context.getStringStore().var(memberName), headerValue); } else { - writer.write("$L: [() => void 0 !== $L, () => $L],", memberName, checkedValue, headerValue); + writer.write( + "[$L]: [() => void 0 !== $L, () => $L],", + context.getStringStore().var(memberName), checkedValue, headerValue + ); } } } diff --git a/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/integration/HttpRpcProtocolGenerator.java b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/integration/HttpRpcProtocolGenerator.java index bb63d58f91f..87b760b584c 100644 --- a/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/integration/HttpRpcProtocolGenerator.java +++ b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/integration/HttpRpcProtocolGenerator.java @@ -173,6 +173,10 @@ public void generateSharedComponents(GenerationContext context) { // Write common request header to be shared by all requests writeSharedRequestHeaders(context); writer.write(""); + + writer.write( + context.getStringStore().getIncremental() + ); } @Override diff --git a/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/integration/ProtocolGenerator.java b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/integration/ProtocolGenerator.java index 21adc02411f..892064207c2 100644 --- a/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/integration/ProtocolGenerator.java +++ b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/integration/ProtocolGenerator.java @@ -30,6 +30,7 @@ import software.amazon.smithy.typescript.codegen.TypeScriptDelegator; import software.amazon.smithy.typescript.codegen.TypeScriptSettings; import software.amazon.smithy.typescript.codegen.TypeScriptWriter; +import software.amazon.smithy.typescript.codegen.util.StringStore; import software.amazon.smithy.utils.CaseUtils; import software.amazon.smithy.utils.SmithyUnstableApi; @@ -313,6 +314,7 @@ class GenerationContext { private TypeScriptDelegator writerDelegator; private TypeScriptWriter writer; private String protocolName; + private StringStore stringStore = new StringStore(); public TypeScriptSettings getSettings() { return settings; @@ -400,5 +402,9 @@ public GenerationContext withWriter(TypeScriptWriter newWriter) { copyContext.setWriter(newWriter); return copyContext; } + + public StringStore getStringStore() { + return stringStore; + } } } diff --git a/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/util/StringStore.java b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/util/StringStore.java new file mode 100644 index 00000000000..f821cee012d --- /dev/null +++ b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/util/StringStore.java @@ -0,0 +1,131 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.typescript.codegen.util; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.Map; +import java.util.Queue; +import java.util.Set; +import java.util.TreeMap; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Intended for use at the + * {@link software.amazon.smithy.typescript.codegen.integration.ProtocolGenerator.GenerationContext} + * level, this class allocates and tracks variables assigned to string literals, allowing a + * form of compression on long protocol serde files. + */ +public class StringStore { + private final ConcurrentHashMap literalToVariable = new ConcurrentHashMap<>(); + private final ConcurrentHashMap variableToLiteral = new ConcurrentHashMap<>(); + private final Set writelog = new HashSet<>(); + + /** + * @param literal - a literal string value. + * @return the variable name assigned for that string, which may have been encountered before. + */ + public String var(String literal) { + if (literal == null) { + throw new RuntimeException("Literal must not be null."); + } + if (literalToVariable.containsKey(literal)) { + return literalToVariable.get(literal); + } + literalToVariable.put(literal, this.assignKey(literal)); + return literalToVariable.get(literal); + } + + /** + * Outputs the generated code for any constants that have been + * allocated but not yet retrieved. + */ + public String getIncremental() { + TreeMap map = new TreeMap<>(variableToLiteral); + + StringBuilder sourceCode = new StringBuilder(); + Set incrementalKeys = new HashSet<>(); + + for (Map.Entry entry : map.entrySet()) { + String v = entry.getKey(); + String l = entry.getValue(); + if (writelog.contains(v)) { + // sourceCode.append(String.format("// const %s = \"%s\";%n", v, l)); + } else { + incrementalKeys.add(v); + sourceCode.append(String.format("const %s = \"%s\";%n", v, l)); + } + } + + writelog.addAll(incrementalKeys); + + return sourceCode.toString(); + } + + /** + * Assigns a new variable or returns the existing variable for a given string literal. + */ + private String assignKey(String literal) { + if (literalToVariable.containsKey(literal)) { + return literalToVariable.get(literal); + } + String variable = allocateVariable(literal); + variableToLiteral.put(variable, literal); + literalToVariable.put(literal, variable); + return variable; + } + + /** + * Assigns a unique variable using the letters from the literal. + * Prefers the uppercase or word-starting letters. + */ + private String allocateVariable(String literal) { + String[] sections = literal.split("[-_\\s]"); + StringBuilder v = new StringBuilder("_"); + Queue deconfliction = new LinkedList<>(); + if (sections.length > 1) { + Arrays.stream(sections) + .map(s -> s.charAt(0)) + .filter(this::isAllowedChar) + .forEach(v::append); + } else { + for (int i = 0; i < literal.length(); ++i) { + char c = literal.charAt(i); + if ((c >= 'A' && c <= 'Z') || (isNeutral(v.toString()) && isAllowedChar(c))) { + v.append(c); + } else if (isAllowedChar(c)) { + deconfliction.add(c); + } + } + } + if (v.isEmpty()) { + v.append("v"); + } + while (variableToLiteral.containsKey(v.toString())) { + if (!deconfliction.isEmpty()) { + v.append(deconfliction.poll()); + } else { + v.append('_'); + } + } + return v.toString(); + } + + /** + * char is in A-Za-z. + */ + private boolean isAllowedChar(char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'); + } + + /** + * @return true if the variable has only underscores. + */ + private boolean isNeutral(String variable) { + return variable.chars().allMatch(c -> c == '_'); + } +}