diff --git a/buildSrc/src/main/kotlin/smithy-java.codegen-plugin-conventions.gradle.kts b/buildSrc/src/main/kotlin/smithy-java.codegen-plugin-conventions.gradle.kts index e7a5b670e..9d0da7966 100644 --- a/buildSrc/src/main/kotlin/smithy-java.codegen-plugin-conventions.gradle.kts +++ b/buildSrc/src/main/kotlin/smithy-java.codegen-plugin-conventions.gradle.kts @@ -17,7 +17,7 @@ dependencies { // Avoid circular dependency in codegen core if (project.name != "core") { - implementation(project(":codegen:core")) + api(project(":codegen:core")) } } diff --git a/client/waiters/build.gradle.kts b/client/waiters/build.gradle.kts new file mode 100644 index 000000000..465cd19fa --- /dev/null +++ b/client/waiters/build.gradle.kts @@ -0,0 +1,15 @@ +plugins { + id("smithy-java.module-conventions") +} + +description = "This module provides the Smithy Java Waiter implementation" + +extra["displayName"] = "Smithy :: Java :: Waiters" +extra["moduleName"] = "software.amazon.smithy.java.waiters" + +dependencies { + api(libs.smithy.waiters) + implementation(project(":jmespath")) + implementation(project(":logging")) + implementation(project(":client:client-core")) +} diff --git a/client/waiters/src/main/java/software/amazon/smithy/java/waiters/Acceptor.java b/client/waiters/src/main/java/software/amazon/smithy/java/waiters/Acceptor.java new file mode 100644 index 000000000..70cbcafe6 --- /dev/null +++ b/client/waiters/src/main/java/software/amazon/smithy/java/waiters/Acceptor.java @@ -0,0 +1,24 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.waiters; + +import java.util.Objects; +import software.amazon.smithy.java.core.schema.SerializableStruct; +import software.amazon.smithy.java.waiters.matching.Matcher; + +/** + * Causes a waiter to transition states if the polling function input/output match a condition. + * + * @param Input type of polling function. + * @param Output type of polling function. + */ +record Acceptor( + WaiterState state, + Matcher matcher) { + Acceptor { + Objects.requireNonNull(matcher, "matcher cannot be null"); + } +} diff --git a/client/waiters/src/main/java/software/amazon/smithy/java/waiters/Waiter.java b/client/waiters/src/main/java/software/amazon/smithy/java/waiters/Waiter.java new file mode 100644 index 000000000..45613e0f8 --- /dev/null +++ b/client/waiters/src/main/java/software/amazon/smithy/java/waiters/Waiter.java @@ -0,0 +1,264 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.waiters; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import software.amazon.smithy.java.client.core.RequestOverrideConfig; +import software.amazon.smithy.java.core.error.ModeledException; +import software.amazon.smithy.java.core.schema.SerializableStruct; +import software.amazon.smithy.java.waiters.backoff.BackoffStrategy; +import software.amazon.smithy.java.waiters.matching.Matcher; + +/** + * Waiters are used to poll a resource until a desired state is reached, or until it is determined that the resource + * has reached an undesirable terminal state. + * + *

Waiters will repeatedly poll for the state of a resource using a provided polling function. The state of the + * resource is then evaluated using a number of {@code Acceptor}s. These acceptors are evaluated in a fixed order and + * can transition the state of the waiter if they determine the resource state matches some condition. + * + *

{@code SUCCESS} and {@code FAILURE} states are terminal states for Waiters and will cause the waiter to complete, returning the + * terminal status. The default waiter state {@code RETRY} causes the waiter to retry polling the resource state. Retries are + * performed using an exponential backoff approach with jitter. + * + *

Example usage

{@code
+ * var waiter = Waiter.builder(client::getFoo)
+ *                 .success(Matcher.output(o -> o.status().equals("DONE")))
+ *                 .failure(Matcher.output(o -> o.status().equals("STOPPED")))
+ *                 .build();
+ * waiter.wait(GetFooInput.builder().id("my-id").build(), 1000);
+ * }
+ * + * @param Input type of resource polling function. + * @param Output type of resource polling function. + * @see Waiter Specification + */ +public final class Waiter implements WaiterSettings { + private final Waitable pollingFunction; + private final List> acceptors; + private BackoffStrategy backoffStrategy; + private RequestOverrideConfig overrideConfig; + + private Waiter(Builder builder) { + this.pollingFunction = builder.pollingFunction; + this.acceptors = Collections.unmodifiableList(builder.acceptors); + this.backoffStrategy = Objects.requireNonNullElse(builder.backoffStrategy, BackoffStrategy.getDefault()); + } + + /** + * Wait for the resource to reach a terminal state. + * + * @param input Input to use for polling function. + * @param maxWaitTime maximum amount of time for waiter to wait. + * @throws WaiterFailureException if the waiter reaches a FAILURE state + */ + public void wait(I input, Duration maxWaitTime) { + wait(input, maxWaitTime.toMillis()); + } + + /** + * Wait for the resource to reach a terminal state. + * + * @param input Input to use for polling function. + * @param maxWaitTimeMillis maximum wait time + * @throws WaiterFailureException if the waiter reaches a FAILURE state + */ + public void wait(I input, long maxWaitTimeMillis) { + int attemptNumber = 0; + long startTime = System.currentTimeMillis(); + + while (true) { + attemptNumber++; + + ModeledException exception = null; + O output = null; + // Execute call to get input and output types + try { + output = pollingFunction.poll(input, overrideConfig); + } catch (ModeledException modeledException) { + exception = modeledException; + } catch (Exception exc) { + throw WaiterFailureException.builder() + .message("Waiter encountered unexpected, unmodeled exception while polling.") + .attemptNumber(attemptNumber) + .cause(exc) + .totalTimeMillis(System.currentTimeMillis() - startTime) + .build(); + } + + WaiterState state; + try { + state = resolveState(input, output, exception); + } catch (Exception exc) { + throw WaiterFailureException.builder() + .message("Waiter encountered unexpected exception.") + .cause(exc) + .attemptNumber(attemptNumber) + .totalTimeMillis(System.currentTimeMillis() - startTime) + .build(); + } + + switch (state) { + case SUCCESS: + return; + case RETRY: + waitToRetry(attemptNumber, maxWaitTimeMillis, startTime); + break; + case FAILURE: + throw WaiterFailureException.builder() + .message("Waiter reached terminal, FAILURE state") + .attemptNumber(attemptNumber) + .totalTimeMillis(System.currentTimeMillis() - startTime) + .build(); + } + } + } + + private WaiterState resolveState(I input, O output, ModeledException exception) { + // Update state based on first matcher that matches + for (Acceptor acceptor : acceptors) { + if (acceptor.matcher().matches(input, output, exception)) { + return acceptor.state(); + } + } + + // If there was an unmatched exception return failure + if (exception != null) { + throw exception; + } + + // Otherwise retry + return WaiterState.RETRY; + } + + private void waitToRetry(int attemptNumber, long maxWaitTimeMillis, long startTimeMillis) { + long elapsedTimeMillis = System.currentTimeMillis() - startTimeMillis; + long remainingTime = maxWaitTimeMillis - elapsedTimeMillis; + + if (remainingTime < 0) { + throw WaiterFailureException.builder() + .message("Waiter timed out after " + attemptNumber + " retry attempts.") + .attemptNumber(attemptNumber) + .totalTimeMillis(elapsedTimeMillis) + .build(); + } + var delay = backoffStrategy.computeNextDelayInMills(attemptNumber, remainingTime); + try { + Thread.sleep(delay); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw WaiterFailureException.builder() + .message("Waiter interrupted while waiting to retry.") + .attemptNumber(attemptNumber) + .totalTimeMillis(System.currentTimeMillis() - startTimeMillis) + .build(); + } + } + + @Override + public void backoffStrategy(BackoffStrategy backoffStrategy) { + this.backoffStrategy = Objects.requireNonNull(backoffStrategy, "backoffStrategy cannot be null."); + } + + @Override + public void overrideConfig(RequestOverrideConfig overrideConfig) { + this.overrideConfig = Objects.requireNonNull(overrideConfig, "overrideConfig cannot be null."); + } + + /** + * Create a new {@link Builder}. + * + * @param pollingFunction Client call that will be used to poll for the resource state. + * @return new {@link Builder} instance. + * @param Input shape type + * @param Output shape type + */ + public static Builder builder(Waitable pollingFunction) { + return new Builder<>(pollingFunction); + } + + /** + * Static builder for {@link Waiter}. + * + * @param Polling function input shape type + * @param Polling function output shape type + */ + public static final class Builder { + private final List> acceptors = new ArrayList<>(); + private final Waitable pollingFunction; + private BackoffStrategy backoffStrategy; + + private Builder(Waitable pollingFunction) { + this.pollingFunction = pollingFunction; + } + + /** + * Add a matcher to the Waiter that will transition the waiter to a SUCCESS state if matched. + * + * @param matcher matcher to add + * @return this builder + */ + public Builder success(Matcher matcher) { + this.acceptors.add(new Acceptor<>(WaiterState.SUCCESS, matcher)); + return this; + } + + /** + * Add a matcher to the Waiter that will transition the waiter to a FAILURE state if matched. + * + * @param matcher matcher to add + * @return this builder + */ + public Builder failure(Matcher matcher) { + this.acceptors.add(new Acceptor<>(WaiterState.FAILURE, matcher)); + return this; + } + + /** + * Add a matcher to the Waiter that will transition the waiter to a FAILURE state if matched. + * + * @param matcher acceptor to add + * @return this builder + */ + public Builder retry(Matcher matcher) { + this.acceptors.add(new Acceptor<>(WaiterState.RETRY, matcher)); + return this; + } + + /** + * Backoff strategy to use when polling for resource state. + * + * @param backoffStrategy backoff strategy to use + * @return this builder + */ + public Builder backoffStrategy(BackoffStrategy backoffStrategy) { + this.backoffStrategy = Objects.requireNonNull(backoffStrategy, "backoffStrategy cannot be null"); + return this; + } + + /** + * Create an immutable {@link Waiter} instance. + * + * @return the built {@code Waiter} object. + */ + public Waiter build() { + return new Waiter<>(this); + } + } + + /** + * Interface representing a function that can be polled for the state of a resource. + */ + @FunctionalInterface + public interface Waitable { + O poll(I input, RequestOverrideConfig requestContext); + } +} diff --git a/client/waiters/src/main/java/software/amazon/smithy/java/waiters/WaiterFailureException.java b/client/waiters/src/main/java/software/amazon/smithy/java/waiters/WaiterFailureException.java new file mode 100644 index 000000000..815a47fb7 --- /dev/null +++ b/client/waiters/src/main/java/software/amazon/smithy/java/waiters/WaiterFailureException.java @@ -0,0 +1,81 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.waiters; + +import java.time.Duration; +import java.util.Objects; + +/** + * Indicates that a {@link Waiter} reached a terminal, FAILURE state. + * + *

{@code Waiter}'s can reach a terminal FAILURE state if: + *

    + *
  • A matching FAILURE acceptor transitions the {@code Waiter} to a FAILURE state.
  • + *
  • The {@code Waiter} times out.
  • + *
  • The {@code Waiter} encounters an unknown exception.
  • + *
+ */ +public final class WaiterFailureException extends RuntimeException { + private final int attemptNumber; + private final long totalTimeMillis; + + private WaiterFailureException(Builder builder) { + super(Objects.requireNonNull(builder.message, "message cannot be null."), builder.cause); + this.attemptNumber = builder.attemptNumber; + this.totalTimeMillis = builder.totalTimeMillis; + } + + public int getAttemptNumber() { + return attemptNumber; + } + + public Duration getTotalTime() { + return Duration.ofMillis(totalTimeMillis); + } + + /** + * @return new static builder for {@link WaiterFailureException}. + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Static builder for {@link WaiterFailureException}. + */ + public static final class Builder { + private Throwable cause; + private String message; + private int attemptNumber; + private long totalTimeMillis; + + private Builder() {} + + public Builder message(String message) { + this.message = message; + return this; + } + + public Builder cause(Throwable cause) { + this.cause = cause; + return this; + } + + public Builder attemptNumber(int attemptNumber) { + this.attemptNumber = attemptNumber; + return this; + } + + public Builder totalTimeMillis(long totalTimeMillis) { + this.totalTimeMillis = totalTimeMillis; + return this; + } + + public WaiterFailureException build() { + return new WaiterFailureException(this); + } + } +} diff --git a/client/waiters/src/main/java/software/amazon/smithy/java/waiters/WaiterSettings.java b/client/waiters/src/main/java/software/amazon/smithy/java/waiters/WaiterSettings.java new file mode 100644 index 000000000..a111f9faf --- /dev/null +++ b/client/waiters/src/main/java/software/amazon/smithy/java/waiters/WaiterSettings.java @@ -0,0 +1,24 @@ +package software.amazon.smithy.java.waiters; + +import software.amazon.smithy.java.client.core.RequestOverrideConfig; +import software.amazon.smithy.java.waiters.backoff.BackoffStrategy; + +/** + * Common settings for all Waiters. + */ +interface WaiterSettings { + /** + * Set the strategy to use to calculate wait time between polling requests. + * + * @param backoffStrategy strategy to use to calculate wait time between polling requests. + */ + void backoffStrategy(BackoffStrategy backoffStrategy); + + /** + * Set the override config to use for polling requests. + * + * @param overrideConfig override config to use for polling requests + */ + void overrideConfig(RequestOverrideConfig overrideConfig); +} + diff --git a/client/waiters/src/main/java/software/amazon/smithy/java/waiters/WaiterState.java b/client/waiters/src/main/java/software/amazon/smithy/java/waiters/WaiterState.java new file mode 100644 index 000000000..facfce9ca --- /dev/null +++ b/client/waiters/src/main/java/software/amazon/smithy/java/waiters/WaiterState.java @@ -0,0 +1,23 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.waiters; + +enum WaiterState { + /** + * Indicates the waiter succeeded and must no longer continue waiting. + */ + SUCCESS, + + /** + * Indicates the waiter failed and must not continue waiting. + */ + FAILURE, + + /** + * Indicates that the waiter encountered an expected failure case and should retry if possible. + */ + RETRY; +} diff --git a/client/waiters/src/main/java/software/amazon/smithy/java/waiters/backoff/BackoffStrategy.java b/client/waiters/src/main/java/software/amazon/smithy/java/waiters/backoff/BackoffStrategy.java new file mode 100644 index 000000000..d3f754f20 --- /dev/null +++ b/client/waiters/src/main/java/software/amazon/smithy/java/waiters/backoff/BackoffStrategy.java @@ -0,0 +1,60 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.waiters.backoff; + +import java.time.Duration; + +/** + * Algorithm that computes the amount of time for a Waiter to wait before attempting a retry. + * + *

NOTEThe default strategy is exponential backoff with jitter. + * @see Waiter specification + */ +public interface BackoffStrategy { + /** + * Computes time to wait for the next retry attempt in milliseconds. + * + *

Note:package private for testing. + * + * @see Waiter Specification + */ + long computeNextDelayInMills(int attempt, long remainingTime); + + /** + * Get a new backoff strategy implementing the default backoff strategy. + * + * @param minDelayMillis minimum delay between attempts, in milliseconds + * @param maxDelayMillis maximum delay between attempts, in milliseconds + * @return new, default backoff strategy + */ + static BackoffStrategy getDefault(Long minDelayMillis, Long maxDelayMillis) { + return new DefaultBackoffStrategy(minDelayMillis, maxDelayMillis); + } + + /** + * Get a new backoff strategy implementing the default backoff strategy. + * + * @param minDelay minimum delay between attempts + * @param maxDelay maximum delay between attempts + * @return new, default backoff strategy + */ + static BackoffStrategy getDefault(Duration minDelay, Duration maxDelay) { + return new DefaultBackoffStrategy( + minDelay != null ? minDelay.toMillis() : null, + maxDelay != null ? maxDelay.toMillis() : null); + } + + /** + * Get a new backoff strategy implementing the default backoff strategy. + * + *

Note:Uses min delay of 2 seconds and max delay of 120 seconds. + * + * @return new, default backoff strategy + */ + static BackoffStrategy getDefault() { + return new DefaultBackoffStrategy(null, null); + } +} diff --git a/client/waiters/src/main/java/software/amazon/smithy/java/waiters/backoff/DefaultBackoffStrategy.java b/client/waiters/src/main/java/software/amazon/smithy/java/waiters/backoff/DefaultBackoffStrategy.java new file mode 100644 index 000000000..f9ed5e002 --- /dev/null +++ b/client/waiters/src/main/java/software/amazon/smithy/java/waiters/backoff/DefaultBackoffStrategy.java @@ -0,0 +1,39 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.waiters.backoff; + +import java.util.Objects; +import java.util.Random; + +// TODO: Add additional testing of exponential backoff impl +final class DefaultBackoffStrategy implements BackoffStrategy { + private static final long defaultMinDelayMillis = 2; + private static final long defaultMaxDelayMillis = 120000; + + private final long minDelayMillis; + private final long maxDelayMillis; + private final Random rng; + + DefaultBackoffStrategy(Long minDelayMillis, Long maxDelayMillis) { + this.minDelayMillis = Objects.requireNonNullElse(minDelayMillis, defaultMinDelayMillis); + this.maxDelayMillis = Objects.requireNonNullElse(maxDelayMillis, defaultMaxDelayMillis); + this.rng = new Random(); + } + + @Override + public long computeNextDelayInMills(int attempt, long remainingTime) { + var attemptCeiling = (Math.log((double) maxDelayMillis / minDelayMillis) / Math.log(2)) + 1; + long delay = maxDelayMillis; + if (((double) attempt) < attemptCeiling) { + delay = Math.min(maxDelayMillis, (long) Math.pow(minDelayMillis * 2, attempt)); + } + delay = rng.nextLong(minDelayMillis, delay); + if (remainingTime - delay <= minDelayMillis) { + delay = remainingTime; + } + return delay; + } +} diff --git a/client/waiters/src/main/java/software/amazon/smithy/java/waiters/jmespath/Comparator.java b/client/waiters/src/main/java/software/amazon/smithy/java/waiters/jmespath/Comparator.java new file mode 100644 index 000000000..948cb97f9 --- /dev/null +++ b/client/waiters/src/main/java/software/amazon/smithy/java/waiters/jmespath/Comparator.java @@ -0,0 +1,75 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.waiters.jmespath; + +import software.amazon.smithy.java.core.serde.document.Document; +import software.amazon.smithy.model.shapes.ShapeType; + +/** + * Used to compare the result of a JMESPath expression with the expected value. + */ +public enum Comparator { + STRING_EQUALS { + @Override + public boolean compare(Document value, String expected) { + if (value == null) { + return false; + } + return switch (value.type()) { + case STRING -> value.asString().equals(expected); + case BYTE, SHORT, INT_ENUM, INTEGER, FLOAT, DOUBLE, BIG_DECIMAL, BIG_INTEGER -> + expected.equals(value.asNumber().toString()); + default -> false; + }; + } + }, + BOOLEAN_EQUALS { + @Override + public boolean compare(Document value, String expected) { + if (value != null && value.type().equals(ShapeType.BOOLEAN)) { + return Boolean.parseBoolean(expected) == value.asBoolean(); + } + return false; + } + }, + ALL_STRING_EQUALS { + @Override + public boolean compare(Document value, String expected) { + if (!value.type().equals(ShapeType.LIST)) { + return false; + } + var documentList = value.asList(); + + if (documentList.isEmpty()) { + return false; + } + + for (var document : documentList) { + if (Comparator.STRING_EQUALS.compare(document, expected)) { + return false; + } + } + return true; + } + }, + ANY_STRING_EQUALS { + @Override + public boolean compare(Document value, String expected) { + if (!value.type().equals(ShapeType.LIST)) { + return false; + } + var documentList = value.asList(); + for (var document : documentList) { + if (Comparator.STRING_EQUALS.compare(document, expected)) { + return true; + } + } + return false; + } + }; + + abstract boolean compare(Document value, String expected); +} diff --git a/client/waiters/src/main/java/software/amazon/smithy/java/waiters/jmespath/InputOutputAwareJMESPathDocumentVisitor.java b/client/waiters/src/main/java/software/amazon/smithy/java/waiters/jmespath/InputOutputAwareJMESPathDocumentVisitor.java new file mode 100644 index 000000000..a91501ac4 --- /dev/null +++ b/client/waiters/src/main/java/software/amazon/smithy/java/waiters/jmespath/InputOutputAwareJMESPathDocumentVisitor.java @@ -0,0 +1,140 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.waiters.jmespath; + +import java.util.Objects; +import software.amazon.smithy.java.core.serde.document.Document; +import software.amazon.smithy.java.jmespath.JMESPathDocumentQuery; +import software.amazon.smithy.jmespath.ExpressionVisitor; +import software.amazon.smithy.jmespath.ast.AndExpression; +import software.amazon.smithy.jmespath.ast.ComparatorExpression; +import software.amazon.smithy.jmespath.ast.CurrentExpression; +import software.amazon.smithy.jmespath.ast.ExpressionTypeExpression; +import software.amazon.smithy.jmespath.ast.FieldExpression; +import software.amazon.smithy.jmespath.ast.FilterProjectionExpression; +import software.amazon.smithy.jmespath.ast.FlattenExpression; +import software.amazon.smithy.jmespath.ast.FunctionExpression; +import software.amazon.smithy.jmespath.ast.IndexExpression; +import software.amazon.smithy.jmespath.ast.LiteralExpression; +import software.amazon.smithy.jmespath.ast.MultiSelectHashExpression; +import software.amazon.smithy.jmespath.ast.MultiSelectListExpression; +import software.amazon.smithy.jmespath.ast.NotExpression; +import software.amazon.smithy.jmespath.ast.ObjectProjectionExpression; +import software.amazon.smithy.jmespath.ast.OrExpression; +import software.amazon.smithy.jmespath.ast.ProjectionExpression; +import software.amazon.smithy.jmespath.ast.SliceExpression; +import software.amazon.smithy.jmespath.ast.Subexpression; + +/** + * Provides custom handling JMESPath expression that may have input/output evaluated in Waiters. + * + *

Waiters special-case the `input` and `output` keywords, allowing users to target those + * shapes in their JMESPath expressions. This decorator handles such JMESPath expressions. + */ +record InputOutputAwareJMESPathDocumentVisitor(Document input, Document output) implements ExpressionVisitor { + private static final String INPUT_NAME = "input"; + private static final String OUTPUT_NAME = "output"; + + InputOutputAwareJMESPathDocumentVisitor { + Objects.requireNonNull(output, "output cannot be null"); + } + + @Override + public Document visitComparator(ComparatorExpression comparatorExpression) { + return JMESPathDocumentQuery.query(comparatorExpression, output); + } + + @Override + public Document visitCurrentNode(CurrentExpression currentExpression) { + return JMESPathDocumentQuery.query(currentExpression, output); + } + + @Override + public Document visitExpressionType(ExpressionTypeExpression expressionTypeExpression) { + return JMESPathDocumentQuery.query(expressionTypeExpression, output); + } + + @Override + public Document visitFlatten(FlattenExpression flattenExpression) { + return JMESPathDocumentQuery.query(flattenExpression, output); + } + + @Override + public Document visitFunction(FunctionExpression functionExpression) { + return JMESPathDocumentQuery.query(functionExpression, output); + } + + @Override + public Document visitField(FieldExpression fieldExpression) { + if (input != null && INPUT_NAME.equals(fieldExpression.getName())) { + return input; + } else if (OUTPUT_NAME.equals(fieldExpression.getName())) { + return output; + } + return JMESPathDocumentQuery.query(fieldExpression, output); + } + + @Override + public Document visitIndex(IndexExpression indexExpression) { + return JMESPathDocumentQuery.query(indexExpression, output); + } + + @Override + public Document visitLiteral(LiteralExpression literalExpression) { + return JMESPathDocumentQuery.query(literalExpression, output); + } + + @Override + public Document visitMultiSelectList(MultiSelectListExpression multiSelectListExpression) { + return JMESPathDocumentQuery.query(multiSelectListExpression, output); + } + + @Override + public Document visitMultiSelectHash(MultiSelectHashExpression multiSelectHashExpression) { + return JMESPathDocumentQuery.query(multiSelectHashExpression, output); + } + + @Override + public Document visitAnd(AndExpression andExpression) { + return JMESPathDocumentQuery.query(andExpression, output); + } + + @Override + public Document visitOr(OrExpression orExpression) { + return JMESPathDocumentQuery.query(orExpression, output); + } + + @Override + public Document visitNot(NotExpression notExpression) { + return JMESPathDocumentQuery.query(notExpression, output); + } + + @Override + public Document visitProjection(ProjectionExpression projectionExpression) { + return JMESPathDocumentQuery.query(projectionExpression, output); + } + + @Override + public Document visitFilterProjection(FilterProjectionExpression filterProjectionExpression) { + return JMESPathDocumentQuery.query(filterProjectionExpression, output); + } + + @Override + public Document visitObjectProjection(ObjectProjectionExpression objectProjectionExpression) { + return JMESPathDocumentQuery.query(objectProjectionExpression, output); + } + + @Override + public Document visitSlice(SliceExpression sliceExpression) { + return JMESPathDocumentQuery.query(sliceExpression, output); + } + + @Override + public Document visitSubexpression(Subexpression subexpression) { + var left = subexpression.getLeft().accept(this); + return JMESPathDocumentQuery.query(subexpression.getRight(), left); + } +} diff --git a/client/waiters/src/main/java/software/amazon/smithy/java/waiters/jmespath/JMESPathBiPredicate.java b/client/waiters/src/main/java/software/amazon/smithy/java/waiters/jmespath/JMESPathBiPredicate.java new file mode 100644 index 000000000..3abeaf829 --- /dev/null +++ b/client/waiters/src/main/java/software/amazon/smithy/java/waiters/jmespath/JMESPathBiPredicate.java @@ -0,0 +1,35 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.waiters.jmespath; + +import java.util.function.BiPredicate; +import software.amazon.smithy.java.core.schema.SerializableStruct; +import software.amazon.smithy.java.core.serde.document.Document; +import software.amazon.smithy.jmespath.JmespathExpression; + +/** + * Tests the input/output of a client call against a JMESPath expression. + * + *

Note:The input shape is optional, but the tested output must be nonnull. + */ +public final class JMESPathBiPredicate implements BiPredicate { + private final JmespathExpression expression; + private final String expected; + private final Comparator comparator; + + public JMESPathBiPredicate(String path, String expected, Comparator comparator) { + this.expression = JmespathExpression.parse(path); + this.expected = expected; + this.comparator = comparator; + } + + @Override + public boolean test(SerializableStruct input, SerializableStruct output) { + var value = + expression.accept(new InputOutputAwareJMESPathDocumentVisitor(Document.of(input), Document.of(output))); + return value != null && comparator.compare(value, expected); + } +} diff --git a/client/waiters/src/main/java/software/amazon/smithy/java/waiters/jmespath/JMESPathPredicate.java b/client/waiters/src/main/java/software/amazon/smithy/java/waiters/jmespath/JMESPathPredicate.java new file mode 100644 index 000000000..6f980dcb7 --- /dev/null +++ b/client/waiters/src/main/java/software/amazon/smithy/java/waiters/jmespath/JMESPathPredicate.java @@ -0,0 +1,32 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.waiters.jmespath; + +import java.util.function.Predicate; +import software.amazon.smithy.java.core.schema.SerializableStruct; +import software.amazon.smithy.java.core.serde.document.Document; +import software.amazon.smithy.jmespath.JmespathExpression; + +/** + * Tests the input and output of a client call against a JMESPath expression. + */ +public final class JMESPathPredicate implements Predicate { + private final JmespathExpression expression; + private final String expected; + private final Comparator comparator; + + public JMESPathPredicate(String path, String expected, Comparator comparator) { + this.expression = JmespathExpression.parse(path); + this.expected = expected; + this.comparator = comparator; + } + + @Override + public boolean test(SerializableStruct output) { + var value = expression.accept(new InputOutputAwareJMESPathDocumentVisitor(null, Document.of(output))); + return value != null && comparator.compare(value, expected); + } +} diff --git a/client/waiters/src/main/java/software/amazon/smithy/java/waiters/matching/ErrorTypeMatcher.java b/client/waiters/src/main/java/software/amazon/smithy/java/waiters/matching/ErrorTypeMatcher.java new file mode 100644 index 000000000..f0a24db0a --- /dev/null +++ b/client/waiters/src/main/java/software/amazon/smithy/java/waiters/matching/ErrorTypeMatcher.java @@ -0,0 +1,25 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.waiters.matching; + +import software.amazon.smithy.java.core.error.ModeledException; +import software.amazon.smithy.java.core.schema.SerializableStruct; +import software.amazon.smithy.model.shapes.ShapeId; + +record ErrorTypeMatcher(String errorName) + implements Matcher { + @Override + public boolean matches(SerializableStruct input, SerializableStruct output, ModeledException exception) { + if (exception == null) { + return false; + } + // If fully qualified ID is provided compare as shapeID + if (errorName.indexOf('#') != -1) { + return ShapeId.from(errorName).equals(exception.schema().id()); + } + return exception.schema().id().getName().equals(errorName); + } +} diff --git a/client/waiters/src/main/java/software/amazon/smithy/java/waiters/matching/InputOutputMatcher.java b/client/waiters/src/main/java/software/amazon/smithy/java/waiters/matching/InputOutputMatcher.java new file mode 100644 index 000000000..7f02b17ae --- /dev/null +++ b/client/waiters/src/main/java/software/amazon/smithy/java/waiters/matching/InputOutputMatcher.java @@ -0,0 +1,21 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.waiters.matching; + +import java.util.function.BiPredicate; +import software.amazon.smithy.java.core.error.ModeledException; +import software.amazon.smithy.java.core.schema.SerializableStruct; + +record InputOutputMatcher(BiPredicate predicate) + implements Matcher { + @Override + public boolean matches(I input, O output, ModeledException exception) { + if (input == null || output == null || exception != null) { + return false; + } + return predicate.test(input, output); + } +} diff --git a/client/waiters/src/main/java/software/amazon/smithy/java/waiters/matching/Matcher.java b/client/waiters/src/main/java/software/amazon/smithy/java/waiters/matching/Matcher.java new file mode 100644 index 000000000..922fd1e2d --- /dev/null +++ b/client/waiters/src/main/java/software/amazon/smithy/java/waiters/matching/Matcher.java @@ -0,0 +1,80 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.waiters.matching; + +import java.util.function.BiPredicate; +import java.util.function.Predicate; +import software.amazon.smithy.java.core.error.ModeledException; +import software.amazon.smithy.java.core.schema.SerializableStruct; + +/** + * A matcher defines how a waiter acceptor determines if it matches the current state of a resource. + * + * @param Input shape type + * @param Output shape type + */ +public sealed interface Matcher + permits ErrorTypeMatcher, InputOutputMatcher, OutputMatcher, SuccessMatcher { + /** + * Checks if the input, output, and/or exception returned by a client call meet a condition. + * + * @param input Input shape type + * @param output Output type if response is successful, or null. + * @param exception Modeled exception if response failed, or null. + * @return true if the matcher matches the client response. + */ + boolean matches(I input, O output, ModeledException exception); + + /** + * Matches on the successful output of an operation. + * + *

This matcher is checked only if an operation completes successfully and has + * non-empty output. + * + * @param predicate Predicate used to match against output values. + */ + static Matcher output(Predicate predicate) { + return new OutputMatcher<>(predicate); + } + + /** + * Matches on both the input and output of a successful operation. + * + *

This matcher is checked only if an operation completes successfully. + * + * @param predicate BiPredicate used to test for a match + * @param Input shape type + * @param Output shape type + */ + static Matcher inputOutput(BiPredicate predicate) { + return new InputOutputMatcher<>(predicate); + } + + /** + * When set to true, matches when an operation returns a successful response. + * + *

When set to false, matches when an operation fails with any error. + * This matcher is checked regardless of if an operation succeeds or fails with an error. + * + * @param onSuccess true if this matches on successful response + */ + static Matcher success(boolean onSuccess) { + return new SuccessMatcher<>(onSuccess); + } + + /** + * Matches if an operation returns an error of an expected type. + * + *

The errorType matcher typically refer to errors that are associated with an operation through its errors property, + * though some operations might need to refer to framework errors that are not defined in the model. + * + * @param errorName Name of error to match + */ + static Matcher errorType(String errorName) { + return new ErrorTypeMatcher<>(errorName); + } +} diff --git a/client/waiters/src/main/java/software/amazon/smithy/java/waiters/matching/OutputMatcher.java b/client/waiters/src/main/java/software/amazon/smithy/java/waiters/matching/OutputMatcher.java new file mode 100644 index 000000000..e46b374d6 --- /dev/null +++ b/client/waiters/src/main/java/software/amazon/smithy/java/waiters/matching/OutputMatcher.java @@ -0,0 +1,21 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.waiters.matching; + +import java.util.function.Predicate; +import software.amazon.smithy.java.core.error.ModeledException; +import software.amazon.smithy.java.core.schema.SerializableStruct; + +record OutputMatcher(Predicate predicate) + implements Matcher { + @Override + public boolean matches(I input, O output, ModeledException exception) { + if (output == null || exception != null) { + return false; + } + return predicate.test(output); + } +} diff --git a/client/waiters/src/main/java/software/amazon/smithy/java/waiters/matching/SuccessMatcher.java b/client/waiters/src/main/java/software/amazon/smithy/java/waiters/matching/SuccessMatcher.java new file mode 100644 index 000000000..420c07e37 --- /dev/null +++ b/client/waiters/src/main/java/software/amazon/smithy/java/waiters/matching/SuccessMatcher.java @@ -0,0 +1,20 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.waiters.matching; + +import software.amazon.smithy.java.core.error.ModeledException; +import software.amazon.smithy.java.core.schema.SerializableStruct; + +record SuccessMatcher(boolean onSuccess) + implements Matcher { + @Override + public boolean matches(SerializableStruct input, SerializableStruct output, ModeledException exception) { + if (exception != null) { + return !onSuccess; + } + return onSuccess; + } +} diff --git a/client/waiters/src/test/java/software/amazon/smithy/java/waiters/MockClient.java b/client/waiters/src/test/java/software/amazon/smithy/java/waiters/MockClient.java new file mode 100644 index 000000000..42089865d --- /dev/null +++ b/client/waiters/src/test/java/software/amazon/smithy/java/waiters/MockClient.java @@ -0,0 +1,50 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.waiters; + +import java.util.Iterator; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import java.util.concurrent.TimeUnit; +import software.amazon.smithy.java.client.core.RequestOverrideConfig; +import software.amazon.smithy.java.core.error.ModeledException; +import software.amazon.smithy.java.core.schema.SerializableStruct; +import software.amazon.smithy.java.waiters.models.GetFoosInput; +import software.amazon.smithy.java.waiters.models.GetFoosOutput; + +final class MockClient { + private final Executor executor = CompletableFuture.delayedExecutor(3, TimeUnit.MILLISECONDS); + private final String expectedId; + private final Iterator iterator; + + MockClient(String expectedId, List outputs) { + this.expectedId = expectedId; + this.iterator = outputs.iterator(); + } + + public GetFoosOutput getFoosSync(GetFoosInput in, RequestOverrideConfig override) { + return getFoosAsync(in, override).join(); + } + + public CompletableFuture getFoosAsync(GetFoosInput in, RequestOverrideConfig override) { + if (!Objects.equals(expectedId, in.id())) { + throw new IllegalArgumentException("ID: " + in.id() + " does not match expected " + expectedId); + } else if (!iterator.hasNext()) { + throw new IllegalArgumentException("No more requests expected but got: " + in); + } + var next = iterator.next(); + if (next instanceof GetFoosOutput output) { + return CompletableFuture.supplyAsync(() -> output, executor); + } else if (next instanceof ModeledException exc) { + // Throw exception + throw exc; + } + + throw new IllegalArgumentException("Expected an output shape or modeled exception. Found: " + next); + } +} diff --git a/client/waiters/src/test/java/software/amazon/smithy/java/waiters/TestWaiters.java b/client/waiters/src/test/java/software/amazon/smithy/java/waiters/TestWaiters.java new file mode 100644 index 000000000..4c294556c --- /dev/null +++ b/client/waiters/src/test/java/software/amazon/smithy/java/waiters/TestWaiters.java @@ -0,0 +1,106 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.waiters; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.java.core.error.ModeledException; +import software.amazon.smithy.java.core.schema.Schema; +import software.amazon.smithy.java.core.serde.ShapeSerializer; +import software.amazon.smithy.java.waiters.backoff.BackoffStrategy; +import software.amazon.smithy.java.waiters.matching.Matcher; +import software.amazon.smithy.java.waiters.models.GetFoosInput; +import software.amazon.smithy.java.waiters.models.GetFoosOutput; + +public class TestWaiters { + private static final String ID = "test-id"; + @Test + void test() { + var client = new MockClient(ID, + List.of( + new GetFoosOutput("BUILDING"), + new GetFoosOutput("BUILDING"), + new GetFoosOutput("BUILDING"), + new GetFoosOutput("DONE"))); + var waiter = Waiter.builder(client::getFoosSync) + .success(Matcher.output(o -> o.status().equals("DONE"))) + .build(); + // Waiter will throw on failure. + waiter.wait(new GetFoosInput(ID), 20000); + } + + @Test + void testWaiterFailureMatch() { + var client = new MockClient(ID, + List.of( + new GetFoosOutput("BUILDING"), + new GetFoosOutput("BUILDING"), + new GetFoosOutput("BUILDING"), + new GetFoosOutput("DONE"))); + var waiter = Waiter.builder(client::getFoosSync) + .failure(Matcher.output(o -> o.status().equals("DONE"))) + .build(); + var exc = assertThrows( + WaiterFailureException.class, + () -> waiter.wait(new GetFoosInput(ID), 20000)); + assertNull(exc.getCause()); + assertEquals(exc.getMessage(), "Waiter reached terminal, FAILURE state"); + } + + @Test + void testWaiterTimesOut() { + var client = new MockClient(ID, + List.of( + new GetFoosOutput("BUILDING"), + new GetFoosOutput("BUILDING"), + new GetFoosOutput("BUILDING"))); + var waiter = Waiter.builder(client::getFoosSync) + .backoffStrategy(BackoffStrategy.getDefault(10L, 20L)) + .failure(Matcher.output(o -> o.status().equals("DONE"))) + .build(); + var exc = assertThrows( + WaiterFailureException.class, + () -> waiter.wait(new GetFoosInput(ID), 10)); + assertNull(exc.getCause()); + assertTrue(exc.getMessage().contains("Waiter timed out after")); + } + + @Test + void testWaiterWrapsError() { + var client = new MockClient(ID, List.of(new UnexpectedException("borked"))); + var waiter = Waiter.builder(client::getFoosSync) + .failure(Matcher.output(o -> o.status().equals("DONE"))) + .build(); + var exc = assertThrows( + WaiterFailureException.class, + () -> waiter.wait(new GetFoosInput(ID), 10)); + assertEquals(exc.getMessage(), "Waiter encountered unexpected exception."); + assertInstanceOf(UnexpectedException.class, exc.getCause()); + } + + private static final class UnexpectedException extends ModeledException { + + private UnexpectedException(String message) { + super(null, message); + } + + @Override + public void serializeMembers(ShapeSerializer serializer) { + // do nothing + } + + @Override + public T getMemberValue(Schema member) { + return null; + } + } +} diff --git a/client/waiters/src/test/java/software/amazon/smithy/java/waiters/jmespath/TestJMESPathMatcher.java b/client/waiters/src/test/java/software/amazon/smithy/java/waiters/jmespath/TestJMESPathMatcher.java new file mode 100644 index 000000000..1aa4425b0 --- /dev/null +++ b/client/waiters/src/test/java/software/amazon/smithy/java/waiters/jmespath/TestJMESPathMatcher.java @@ -0,0 +1,56 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.waiters.jmespath; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import software.amazon.smithy.java.waiters.models.GetFoosOutput; + +public class TestJMESPathMatcher { + // static final Document INPUT = Document.of(Map.of( + // "True", Document.of(true), + // "False", Document.of(false), + // "int", Document.of(1) + // )); + // static final Document OUTPUT = Document.of(Map.of( + // "True", Document.of(true), + // "False", Document.of(false), + // "int", Document.of(1) + // )); + + static List matchNoInputSource() { + return List.of( + Arguments.of("output.status", "DONE", Comparator.STRING_EQUALS, true)); + } + + @ParameterizedTest + @MethodSource("matchNoInputSource") + public void testMatchNoInput( + String path, + String expected, + Comparator comparator, + boolean isMatch + ) { + var matcher = new JMESPathPredicate(path, expected, comparator); + assertEquals(matcher.test(new GetFoosOutput("DONE")), isMatch); + } + + static List matchInputSource() { + return List.of( + Arguments.of("path", "expected", Comparator.STRING_EQUALS, true)); + } + + @ParameterizedTest + @MethodSource("matchInputSource") + public void testMatchInput(String path, String expected, Comparator comparator, boolean isMatch) { + var matcher = new JMESPathPredicate(path, expected, comparator); + //assertEquals(matcher.test(INPUT, OUTPUT), isMatch); + } +} diff --git a/client/waiters/src/test/java/software/amazon/smithy/java/waiters/jmespath/TestWaiterDelegator.java b/client/waiters/src/test/java/software/amazon/smithy/java/waiters/jmespath/TestWaiterDelegator.java new file mode 100644 index 000000000..388183e6d --- /dev/null +++ b/client/waiters/src/test/java/software/amazon/smithy/java/waiters/jmespath/TestWaiterDelegator.java @@ -0,0 +1,48 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.waiters.jmespath; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.util.List; +import java.util.Map; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import software.amazon.smithy.java.core.serde.document.Document; +import software.amazon.smithy.jmespath.JmespathExpression; + +public class TestWaiterDelegator { + + static List delegatorSource() { + return List.of( + Arguments.of("foo", null), + Arguments.of("input.foo", "value"), + Arguments.of("bar", "value"), + Arguments.of("output.bar", "value")); + } + + @ParameterizedTest + @MethodSource("delegatorSource") + void testDelegator(String str, String expected) { + var input = Document.of(Map.of("foo", Document.of("value"))); + var output = Document.of(Map.of( + "bar", + Document.of("value"), + "baz", + Document.of("valve"))); + + var exp = JmespathExpression.parse(str); + var value = exp.accept(new InputOutputAwareJMESPathDocumentVisitor(input, output)); + + if (expected == null) { + assertNull(value); + } else { + assertEquals(expected, value.asString()); + } + } +} diff --git a/client/waiters/src/test/java/software/amazon/smithy/java/waiters/models/GetFoosInput.java b/client/waiters/src/test/java/software/amazon/smithy/java/waiters/models/GetFoosInput.java new file mode 100644 index 000000000..3ef1c8225 --- /dev/null +++ b/client/waiters/src/test/java/software/amazon/smithy/java/waiters/models/GetFoosInput.java @@ -0,0 +1,51 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.waiters.models; + +import software.amazon.smithy.java.core.schema.PreludeSchemas; +import software.amazon.smithy.java.core.schema.Schema; +import software.amazon.smithy.java.core.schema.SchemaUtils; +import software.amazon.smithy.java.core.schema.SerializableStruct; +import software.amazon.smithy.java.core.serde.ShapeSerializer; +import software.amazon.smithy.java.core.serde.ToStringSerializer; +import software.amazon.smithy.model.shapes.ShapeId; + +public record GetFoosInput(String id) implements SerializableStruct { + public static final ShapeId ID = ShapeId.from("smithy.example#GetFoosInput"); + public static final Schema SCHEMA = Schema.structureBuilder(ID) + .putMember("id", PreludeSchemas.STRING) + .build(); + public static final Schema SCHEMA_ID = SCHEMA.member("id"); + + @Override + public String toString() { + return ToStringSerializer.serialize(this); + } + + @Override + public Schema schema() { + return SCHEMA; + } + + @Override + public void serialize(ShapeSerializer encoder) { + encoder.writeStruct(SCHEMA, this); + } + + @Override + public void serializeMembers(ShapeSerializer serializer) { + serializer.writeString(SCHEMA_ID, id); + } + + @Override + @SuppressWarnings("unchecked") + public T getMemberValue(Schema member) { + return switch (member.memberIndex()) { + case 0 -> (T) SchemaUtils.validateSameMember(SCHEMA_ID, member, id); + default -> throw new IllegalArgumentException("Attempted to get non-existent member: " + member.id()); + }; + } +} diff --git a/client/waiters/src/test/java/software/amazon/smithy/java/waiters/models/GetFoosOutput.java b/client/waiters/src/test/java/software/amazon/smithy/java/waiters/models/GetFoosOutput.java new file mode 100644 index 000000000..e07fff484 --- /dev/null +++ b/client/waiters/src/test/java/software/amazon/smithy/java/waiters/models/GetFoosOutput.java @@ -0,0 +1,51 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.waiters.models; + +import software.amazon.smithy.java.core.schema.PreludeSchemas; +import software.amazon.smithy.java.core.schema.Schema; +import software.amazon.smithy.java.core.schema.SchemaUtils; +import software.amazon.smithy.java.core.schema.SerializableStruct; +import software.amazon.smithy.java.core.serde.ShapeSerializer; +import software.amazon.smithy.java.core.serde.ToStringSerializer; +import software.amazon.smithy.model.shapes.ShapeId; + +public record GetFoosOutput(String status) implements SerializableStruct { + private static final ShapeId ID = ShapeId.from("smithy.example#GetFoosOutput"); + static final Schema SCHEMA = Schema.structureBuilder(ID) + .putMember("status", PreludeSchemas.STRING) + .build(); + private static final Schema SCHEMA_STATUS = SCHEMA.member("status"); + + @Override + public String toString() { + return ToStringSerializer.serialize(this); + } + + @Override + public Schema schema() { + return SCHEMA; + } + + @Override + public void serialize(ShapeSerializer encoder) { + encoder.writeStruct(SCHEMA, this); + } + + @Override + public void serializeMembers(ShapeSerializer serializer) { + serializer.writeString(SCHEMA_STATUS, status); + } + + @Override + @SuppressWarnings("unchecked") + public T getMemberValue(Schema member) { + return switch (member.memberIndex()) { + case 0 -> (T) SchemaUtils.validateSameMember(SCHEMA_STATUS, member, status); + default -> throw new IllegalArgumentException("Attempted to get non-existent member: " + member.id()); + }; + } +} diff --git a/client/waiters/src/test/java/software/amazon/smithy/java/waiters/models/TestOperationWaitable.java b/client/waiters/src/test/java/software/amazon/smithy/java/waiters/models/TestOperationWaitable.java new file mode 100644 index 000000000..c76946769 --- /dev/null +++ b/client/waiters/src/test/java/software/amazon/smithy/java/waiters/models/TestOperationWaitable.java @@ -0,0 +1,52 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.waiters.models; + +import java.util.List; +import software.amazon.smithy.java.core.schema.ApiOperation; +import software.amazon.smithy.java.core.schema.Schema; +import software.amazon.smithy.java.core.schema.ShapeBuilder; +import software.amazon.smithy.java.core.serde.TypeRegistry; +import software.amazon.smithy.model.shapes.ShapeId; + +public final class TestOperationWaitable implements ApiOperation { + private static final Schema SCHEMA = Schema.createOperation(ShapeId.from("foo.bar#operationA")); + + @Override + public ShapeBuilder inputBuilder() { + throw new UnsupportedOperationException(); + } + + @Override + public ShapeBuilder outputBuilder() { + throw new UnsupportedOperationException(); + } + + @Override + public Schema schema() { + return SCHEMA; + } + + @Override + public Schema inputSchema() { + return GetFoosInput.SCHEMA; + } + + @Override + public Schema outputSchema() { + return GetFoosOutput.SCHEMA; + } + + @Override + public TypeRegistry errorRegistry() { + throw new UnsupportedOperationException(); + } + + @Override + public List effectiveAuthSchemes() { + throw new UnsupportedOperationException(); + } +} diff --git a/codegen/core/src/main/java/software/amazon/smithy/java/codegen/CodeGenerationContext.java b/codegen/core/src/main/java/software/amazon/smithy/java/codegen/CodeGenerationContext.java index fc3ad4055..48d5c3238 100644 --- a/codegen/core/src/main/java/software/amazon/smithy/java/codegen/CodeGenerationContext.java +++ b/codegen/core/src/main/java/software/amazon/smithy/java/codegen/CodeGenerationContext.java @@ -103,13 +103,15 @@ public class CodeGenerationContext private final WriterDelegator writerDelegator; private final Set runtimeTraits; private final List> traitInitializers; + private final String plugin; public CodeGenerationContext( Model model, JavaCodegenSettings settings, SymbolProvider symbolProvider, FileManifest fileManifest, - List integrations + List integrations, + String plugin ) { this.model = model; this.settings = settings; @@ -122,6 +124,7 @@ public CodeGenerationContext( new JavaWriter.Factory(settings)); this.runtimeTraits = collectRuntimeTraits(); this.traitInitializers = collectTraitInitializers(); + this.plugin = plugin; } @Override @@ -158,6 +161,10 @@ public Set runtimeTraits() { return runtimeTraits; } + public String plugin() { + return plugin; + } + /** * Determines the "runtime traits" for a service, i.e. traits that should be included in Shape schemas. * diff --git a/codegen/core/src/test/java/software/amazon/smithy/java/codegen/CodegenContextTest.java b/codegen/core/src/test/java/software/amazon/smithy/java/codegen/CodegenContextTest.java index 3e40cea4c..f1075b64a 100644 --- a/codegen/core/src/test/java/software/amazon/smithy/java/codegen/CodegenContextTest.java +++ b/codegen/core/src/test/java/software/amazon/smithy/java/codegen/CodegenContextTest.java @@ -72,7 +72,8 @@ void getsCorrectRuntimeTraitsForProtocolsAndAuth() { .build(), new JavaSymbolProvider(model, model.expectShape(SERVICE_ID).asServiceShape().get(), "ns.foo"), new MockManifest(), - List.of()); + List.of(), + "test"); assertThat( context.runtimeTraits(), @@ -129,7 +130,8 @@ void getsCorrectTraitsWithNoProtocolOrAuth() { model.expectShape(NO_PROTOCOL_SERVICE_ID).asServiceShape().get(), "ns.foo"), new MockManifest(), - List.of()); + List.of(), + "test"); assertThat( context.runtimeTraits(), diff --git a/codegen/core/src/test/java/software/amazon/smithy/java/codegen/utils/TestJavaCodegen.java b/codegen/core/src/test/java/software/amazon/smithy/java/codegen/utils/TestJavaCodegen.java index e955afde4..fcb56cc32 100644 --- a/codegen/core/src/test/java/software/amazon/smithy/java/codegen/utils/TestJavaCodegen.java +++ b/codegen/core/src/test/java/software/amazon/smithy/java/codegen/utils/TestJavaCodegen.java @@ -57,7 +57,8 @@ public CodeGenerationContext createContext( directive.settings(), directive.symbolProvider(), directive.fileManifest(), - directive.integrations()); + directive.integrations(), + "test"); } @Override diff --git a/codegen/integrations/waiters-codegen/build.gradle.kts b/codegen/integrations/waiters-codegen/build.gradle.kts new file mode 100644 index 000000000..e290c8b79 --- /dev/null +++ b/codegen/integrations/waiters-codegen/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { + id("smithy-java.module-conventions") +} + +description = "This module provides the Smithy Java Waiter codegen integration" + +extra["displayName"] = "Smithy :: Java :: Waiters :: Codegen" +extra["moduleName"] = "software.amazon.smithy.java.codegen.waiters" + +dependencies { + implementation(project(":client:waiters")) + implementation(project(":codegen:plugins:client")) +} diff --git a/codegen/integrations/waiters-codegen/src/main/java/software/amazon/smithy/java/codegen/client/waiters/WaiterClientImplMethodInterceptor.java b/codegen/integrations/waiters-codegen/src/main/java/software/amazon/smithy/java/codegen/client/waiters/WaiterClientImplMethodInterceptor.java new file mode 100644 index 000000000..b19454bd8 --- /dev/null +++ b/codegen/integrations/waiters-codegen/src/main/java/software/amazon/smithy/java/codegen/client/waiters/WaiterClientImplMethodInterceptor.java @@ -0,0 +1,38 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.codegen.client.waiters; + +import software.amazon.smithy.codegen.core.SymbolProvider; +import software.amazon.smithy.java.codegen.JavaCodegenSettings; +import software.amazon.smithy.java.codegen.client.sections.ClientImplAdditionalMethodsSection; +import software.amazon.smithy.java.codegen.writer.JavaWriter; +import software.amazon.smithy.utils.CodeInterceptor; + +record WaiterClientImplMethodInterceptor(SymbolProvider symbolProvider, JavaCodegenSettings settings) + implements CodeInterceptor.Prepender { + @Override + public Class sectionType() { + return ClientImplAdditionalMethodsSection.class; + } + + @Override + public void prepend(JavaWriter writer, ClientImplAdditionalMethodsSection section) { + // TODO: Support Async waiters + if (section.async()) { + return; + } + var clientSymbol = symbolProvider.toSymbol(section.client()); + writer.pushState(); + writer.putContext("container", WaiterCodegenUtils.getWaiterSymbol(clientSymbol, settings, section.async())); + writer.write(""" + @Override + public ${container:T} waiter() { + return new ${container:T}(this); + } + """); + writer.popState(); + } +} diff --git a/codegen/integrations/waiters-codegen/src/main/java/software/amazon/smithy/java/codegen/client/waiters/WaiterClientInterfaceMethodInterceptor.java b/codegen/integrations/waiters-codegen/src/main/java/software/amazon/smithy/java/codegen/client/waiters/WaiterClientInterfaceMethodInterceptor.java new file mode 100644 index 000000000..897beef4c --- /dev/null +++ b/codegen/integrations/waiters-codegen/src/main/java/software/amazon/smithy/java/codegen/client/waiters/WaiterClientInterfaceMethodInterceptor.java @@ -0,0 +1,40 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.codegen.client.waiters; + +import software.amazon.smithy.codegen.core.SymbolProvider; +import software.amazon.smithy.java.codegen.JavaCodegenSettings; +import software.amazon.smithy.java.codegen.client.sections.ClientInterfaceAdditionalMethodsSection; +import software.amazon.smithy.java.codegen.writer.JavaWriter; +import software.amazon.smithy.utils.CodeInterceptor; + +record WaiterClientInterfaceMethodInterceptor(SymbolProvider symbolProvider, JavaCodegenSettings settings) + implements CodeInterceptor.Prepender { + @Override + public Class sectionType() { + return ClientInterfaceAdditionalMethodsSection.class; + } + + @Override + public void prepend(JavaWriter writer, ClientInterfaceAdditionalMethodsSection section) { + // TODO: Support Async waiters + if (section.async()) { + return; + } + var clientSymbol = symbolProvider.toSymbol(section.client()); + writer.pushState(); + writer.putContext("container", WaiterCodegenUtils.getWaiterSymbol(clientSymbol, settings, section.async())); + writer.write(""" + /** + * Create a new {@link CoffeeShopWaiter} instance that uses this client for polling. + * + * @return new {@link ${container:T}} instance. + */ + ${container:T} waiter(); + """); + writer.popState(); + } +} diff --git a/codegen/integrations/waiters-codegen/src/main/java/software/amazon/smithy/java/codegen/client/waiters/WaiterCodegenIntegration.java b/codegen/integrations/waiters-codegen/src/main/java/software/amazon/smithy/java/codegen/client/waiters/WaiterCodegenIntegration.java new file mode 100644 index 000000000..9374366c6 --- /dev/null +++ b/codegen/integrations/waiters-codegen/src/main/java/software/amazon/smithy/java/codegen/client/waiters/WaiterCodegenIntegration.java @@ -0,0 +1,37 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.codegen.client.waiters; + +import java.util.List; +import software.amazon.smithy.java.codegen.CodeGenerationContext; +import software.amazon.smithy.java.codegen.JavaCodegenIntegration; +import software.amazon.smithy.java.codegen.writer.JavaWriter; +import software.amazon.smithy.utils.CodeInterceptor; +import software.amazon.smithy.utils.CodeSection; +import software.amazon.smithy.utils.SmithyInternalApi; + +@SmithyInternalApi +public final class WaiterCodegenIntegration implements JavaCodegenIntegration { + @Override + public String name() { + return "waiters"; + } + + @Override + public List> interceptors( + CodeGenerationContext context + ) { + return List.of( + new WaiterClientInterfaceMethodInterceptor(context.symbolProvider(), context.settings()), + new WaiterClientImplMethodInterceptor(context.symbolProvider(), context.settings()), + new WaiterDocumentationInterceptor()); + } + + @Override + public void customize(CodeGenerationContext context) { + new WaiterContainerGenerator().accept(context); + } +} diff --git a/codegen/integrations/waiters-codegen/src/main/java/software/amazon/smithy/java/codegen/client/waiters/WaiterCodegenUtils.java b/codegen/integrations/waiters-codegen/src/main/java/software/amazon/smithy/java/codegen/client/waiters/WaiterCodegenUtils.java new file mode 100644 index 000000000..b33669e03 --- /dev/null +++ b/codegen/integrations/waiters-codegen/src/main/java/software/amazon/smithy/java/codegen/client/waiters/WaiterCodegenUtils.java @@ -0,0 +1,34 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.codegen.client.waiters; + +import static java.lang.String.format; + +import software.amazon.smithy.codegen.core.Symbol; +import software.amazon.smithy.java.codegen.JavaCodegenSettings; +import software.amazon.smithy.java.codegen.SymbolProperties; + +final class WaiterCodegenUtils { + /** + * Determine the symbol to use for the Waiter container + * @param clientSymbol Symbol for the client this waiter container is being created for. + * @param settings Code generation settings + * @param async whether this container will contain asynchronous waiters. + * @return Symbol representing waiter container class. + */ + static Symbol getWaiterSymbol(Symbol clientSymbol, JavaCodegenSettings settings, boolean async) { + var baseClientName = clientSymbol.getName().substring(0, clientSymbol.getName().lastIndexOf("Client")); + var waiterName = async ? baseClientName + "AsyncWaiter" : baseClientName + "Waiter"; + return Symbol.builder() + .name(waiterName) + .namespace(format("%s.client", settings.packageNamespace()), ".") + .putProperty(SymbolProperties.IS_PRIMITIVE, false) + .definitionFile(format("./%s/client/%s.java", + settings.packageNamespace().replace(".", "/"), + waiterName)) + .build(); + } +} diff --git a/codegen/integrations/waiters-codegen/src/main/java/software/amazon/smithy/java/codegen/client/waiters/WaiterContainerGenerator.java b/codegen/integrations/waiters-codegen/src/main/java/software/amazon/smithy/java/codegen/client/waiters/WaiterContainerGenerator.java new file mode 100644 index 000000000..5321cd531 --- /dev/null +++ b/codegen/integrations/waiters-codegen/src/main/java/software/amazon/smithy/java/codegen/client/waiters/WaiterContainerGenerator.java @@ -0,0 +1,193 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.codegen.client.waiters; + +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Consumer; +import software.amazon.smithy.codegen.core.SymbolProvider; +import software.amazon.smithy.java.codegen.CodeGenerationContext; +import software.amazon.smithy.java.codegen.CodegenUtils; +import software.amazon.smithy.java.codegen.client.ClientSymbolProperties; +import software.amazon.smithy.java.codegen.writer.JavaWriter; +import software.amazon.smithy.java.waiters.Waiter; +import software.amazon.smithy.java.waiters.backoff.BackoffStrategy; +import software.amazon.smithy.java.waiters.jmespath.Comparator; +import software.amazon.smithy.java.waiters.jmespath.JMESPathBiPredicate; +import software.amazon.smithy.java.waiters.jmespath.JMESPathPredicate; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.knowledge.OperationIndex; +import software.amazon.smithy.model.shapes.ServiceShape; +import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.utils.SmithyGenerated; +import software.amazon.smithy.utils.StringUtils; +import software.amazon.smithy.waiters.Matcher; +import software.amazon.smithy.waiters.WaitableTrait; + +/** + * Generates the waiter container class for a client + */ +final class WaiterContainerGenerator implements Consumer { + + @Override + public void accept(CodeGenerationContext context) { + var serviceShape = context.model().expectShape(context.settings().service(), ServiceShape.class); + var serviceSymbol = context.symbolProvider().toSymbol(serviceShape); + // Only run generator if service is client + if (serviceSymbol.getProperty(ClientSymbolProperties.CLIENT_IMPL).isEmpty()) { + return; + } + // TODO : Allow for async waiters. + var symbol = WaiterCodegenUtils.getWaiterSymbol(serviceSymbol, context.settings(), false); + context.settings().addSymbol(symbol); + context.writerDelegator().useSymbolWriter(symbol, writer -> { + var template = """ + /** + * Waiters for the {@link ${clientType:T}} client. + */ + @${smithyGenerated:T} + public record ${type:T}(${clientType:T} client) { + public ${type:T} { + ${objects:T}.requireNonNull(client, "client cannot be null"); + } + + ${waiters:C|} + } + """; + writer.putContext("smithyGenerated", SmithyGenerated.class); + writer.putContext("type", symbol); + writer.putContext("objects", Objects.class); + var clientSymbol = context.symbolProvider().toSymbol(serviceShape); + writer.putContext("clientType", clientSymbol); + var waitableOperations = context.model().getShapesWithTrait(WaitableTrait.class); + writer.putContext("waiters", + new WaiterGenerator(writer, + context.symbolProvider(), + context.model(), + waitableOperations, + serviceShape)); + writer.write(template); + }); + } + + private record WaiterGenerator( + JavaWriter writer, + SymbolProvider symbolProvider, + Model model, + Set waitableOperations, + ServiceShape service) implements Runnable { + + @Override + public void run() { + writer.pushState(); + OperationIndex index = OperationIndex.of(model); + + writer.putContext("waiterClass", Waiter.class); + + for (var operation : waitableOperations) { + var trait = operation.expectTrait(WaitableTrait.class); + + writer.pushState(); + writer.putContext("input", symbolProvider.toSymbol(index.expectInputShape(operation))); + writer.putContext("output", symbolProvider.toSymbol(index.expectOutputShape(operation))); + writer.putContext("opName", StringUtils.uncapitalize(CodegenUtils.getDefaultName(operation, service))); + writer.putContext("backoff", BackoffStrategy.class); + writer.putContext("deprecated", Deprecated.class); + for (var waiterEntry : trait.getWaiters().entrySet()) { + writer.pushState(new WaiterSection(waiterEntry.getValue())); + writer.putContext("waiterName", StringUtils.uncapitalize(waiterEntry.getKey())); + var waiter = waiterEntry.getValue(); + // Min and max delay on trait are always in seconds. Convert to millis and add to context + writer.putContext("maxDelay", waiter.getMaxDelay() * 1000); + writer.putContext("minDelay", waiter.getMinDelay() * 1000); + writer.putContext("isDeprecated", waiter.isDeprecated()); + + var template = + """ + ${?isDeprecated}@${deprecated:T} + ${/isDeprecated}public ${waiterClass:T}<${input:T}, ${output:T}> ${waiterName:L}() { + return ${waiterClass:T}.<${input:T}, ${output:T}>builder(client::${opName:L}) + .backoffStrategy(${backoff:T}.getDefault(${maxDelay:L}L, ${minDelay:L}L))${#acceptors} + .${key:L}(${value:C|})${/acceptors} + .build(); + } + """; + Map acceptors = new HashMap<>(); + for (var acceptor : waiter.getAcceptors()) { + acceptors.put( + acceptor.getState().name().toLowerCase(Locale.ENGLISH), + new MatcherVisitor(writer, acceptor.getMatcher())); + } + writer.putContext("acceptors", acceptors); + writer.write(template); + writer.popState(); + } + writer.popState(); + } + writer.popState(); + } + } + + private record MatcherVisitor(JavaWriter writer, Matcher matcher) implements Matcher.Visitor, Runnable { + + @Override + public void run() { + writer.pushState(); + writer.putContext("matcherType", software.amazon.smithy.java.waiters.matching.Matcher.class); + matcher.accept(this); + writer.popState(); + } + + @Override + public Void visitOutput(Matcher.OutputMember outputMember) { + writer.pushState(); + writer.putContext("path", outputMember.getValue().getPath()); + writer.putContext("expected", outputMember.getValue().getExpected()); + writer.putContext("comparator", Comparator.class); + writer.putContext("compType", outputMember.getValue().getComparator().name()); + writer.putContext("pred", JMESPathPredicate.class); + writer.write( + "${matcherType:T}.output(new ${pred:T}(${path:S}, ${expected:S}, ${comparator:T}.${compType:L}))"); + writer.popState(); + return null; + } + + @Override + public Void visitInputOutput(Matcher.InputOutputMember inputOutputMember) { + writer.pushState(); + writer.putContext("path", inputOutputMember.getValue().getPath()); + writer.putContext("expected", inputOutputMember.getValue().getExpected()); + writer.putContext("comparator", Comparator.class); + writer.putContext("compType", inputOutputMember.getValue().getComparator().name()); + + writer.putContext("pred", JMESPathBiPredicate.class); + writer.write( + "${matcherType:T}.inputOutput(new ${pred:T}(${path:S}, ${expected:S}, ${comparator:T}.${compType:L}))"); + writer.popState(); + return null; + } + + @Override + public Void visitSuccess(Matcher.SuccessMember successMember) { + writer.write("${matcherType:T}.success($L)", successMember.getValue()); + return null; + } + + @Override + public Void visitErrorType(Matcher.ErrorTypeMember errorTypeMember) { + writer.write("${matcherType:T}.errorType($S)", errorTypeMember.getValue()); + return null; + } + + @Override + public Void visitUnknown(Matcher.UnknownMember unknownMember) { + throw new IllegalArgumentException("Unknown member " + unknownMember); + } + } +} diff --git a/codegen/integrations/waiters-codegen/src/main/java/software/amazon/smithy/java/codegen/client/waiters/WaiterDocumentationInterceptor.java b/codegen/integrations/waiters-codegen/src/main/java/software/amazon/smithy/java/codegen/client/waiters/WaiterDocumentationInterceptor.java new file mode 100644 index 000000000..519637cab --- /dev/null +++ b/codegen/integrations/waiters-codegen/src/main/java/software/amazon/smithy/java/codegen/client/waiters/WaiterDocumentationInterceptor.java @@ -0,0 +1,36 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.codegen.client.waiters; + +import software.amazon.smithy.java.codegen.sections.JavadocSection; +import software.amazon.smithy.java.codegen.writer.JavaWriter; +import software.amazon.smithy.utils.CodeInterceptor; + +/** + * Adds Javadoc documentation for waiter methods. + */ +final class WaiterDocumentationInterceptor implements CodeInterceptor.Appender { + + @Override + public void append(JavaWriter writer, JavadocSection section) { + if (section.parent() instanceof WaiterSection ws) { + ws.waiter().getDocumentation().ifPresent(writer::writeWithNoFormatting); + if (ws.waiter().isDeprecated()) { + writer.write("@deprecated Waiter is deprecated"); + } + } + } + + @Override + public Class sectionType() { + return JavadocSection.class; + } + + @Override + public boolean isIntercepted(JavadocSection section) { + return section.parent() instanceof WaiterSection; + } +} diff --git a/codegen/integrations/waiters-codegen/src/main/java/software/amazon/smithy/java/codegen/client/waiters/WaiterSection.java b/codegen/integrations/waiters-codegen/src/main/java/software/amazon/smithy/java/codegen/client/waiters/WaiterSection.java new file mode 100644 index 000000000..407a0ebe6 --- /dev/null +++ b/codegen/integrations/waiters-codegen/src/main/java/software/amazon/smithy/java/codegen/client/waiters/WaiterSection.java @@ -0,0 +1,24 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.codegen.client.waiters; + +import software.amazon.smithy.java.codegen.sections.DocumentedSection; +import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.utils.CodeSection; +import software.amazon.smithy.waiters.Waiter; + +/** + * Contains a waiter method. + * + * @param waiter waiter method definition contained by this section + */ +record WaiterSection(Waiter waiter) implements CodeSection, DocumentedSection { + @Override + public Shape targetedShape() { + // Never targets a shape. + return null; + } +} diff --git a/codegen/integrations/waiters-codegen/src/main/resources/META-INF/services/software.amazon.smithy.java.codegen.JavaCodegenIntegration b/codegen/integrations/waiters-codegen/src/main/resources/META-INF/services/software.amazon.smithy.java.codegen.JavaCodegenIntegration new file mode 100644 index 000000000..747fbb517 --- /dev/null +++ b/codegen/integrations/waiters-codegen/src/main/resources/META-INF/services/software.amazon.smithy.java.codegen.JavaCodegenIntegration @@ -0,0 +1 @@ +software.amazon.smithy.java.codegen.client.waiters.WaiterCodegenIntegration diff --git a/codegen/plugins/client/build.gradle.kts b/codegen/plugins/client/build.gradle.kts index 24dfa1133..865186577 100644 --- a/codegen/plugins/client/build.gradle.kts +++ b/codegen/plugins/client/build.gradle.kts @@ -8,7 +8,7 @@ extra["displayName"] = "Smithy :: Java :: Codegen :: Plugins :: Client" extra["moduleName"] = "software.amazon.smithy.java.codegen.client" dependencies { - implementation(project(":client:client-core")) + api(project(":client:client-core")) testImplementation(project(":aws:client:aws-client-restjson")) testImplementation(libs.smithy.aws.traits) diff --git a/codegen/plugins/client/src/main/java/software/amazon/smithy/java/codegen/client/DirectedJavaClientCodegen.java b/codegen/plugins/client/src/main/java/software/amazon/smithy/java/codegen/client/DirectedJavaClientCodegen.java index e90ce4492..2a04f3be2 100644 --- a/codegen/plugins/client/src/main/java/software/amazon/smithy/java/codegen/client/DirectedJavaClientCodegen.java +++ b/codegen/plugins/client/src/main/java/software/amazon/smithy/java/codegen/client/DirectedJavaClientCodegen.java @@ -47,7 +47,8 @@ public CodeGenerationContext createContext( directive.settings(), directive.symbolProvider(), directive.fileManifest(), - directive.integrations()); + directive.integrations(), + "client"); } @Override diff --git a/codegen/plugins/client/src/main/java/software/amazon/smithy/java/codegen/client/generators/ClientImplementationGenerator.java b/codegen/plugins/client/src/main/java/software/amazon/smithy/java/codegen/client/generators/ClientImplementationGenerator.java index 9c09a26c1..ffbbc60d9 100644 --- a/codegen/plugins/client/src/main/java/software/amazon/smithy/java/codegen/client/generators/ClientImplementationGenerator.java +++ b/codegen/plugins/client/src/main/java/software/amazon/smithy/java/codegen/client/generators/ClientImplementationGenerator.java @@ -20,6 +20,7 @@ import software.amazon.smithy.java.codegen.CodegenUtils; import software.amazon.smithy.java.codegen.JavaCodegenSettings; import software.amazon.smithy.java.codegen.client.ClientSymbolProperties; +import software.amazon.smithy.java.codegen.client.sections.ClientImplAdditionalMethodsSection; import software.amazon.smithy.java.codegen.generators.TypeRegistryGenerator; import software.amazon.smithy.java.codegen.sections.ApplyDocumentation; import software.amazon.smithy.java.codegen.sections.ClassSection; @@ -127,6 +128,7 @@ public void run() { writer.write(template); writer.popState(); } + writer.injectSection(new ClientImplAdditionalMethodsSection(service, async)); writer.popState(); } } diff --git a/codegen/plugins/client/src/main/java/software/amazon/smithy/java/codegen/client/generators/ClientInterfaceGenerator.java b/codegen/plugins/client/src/main/java/software/amazon/smithy/java/codegen/client/generators/ClientInterfaceGenerator.java index 63ad6b326..2320ed761 100644 --- a/codegen/plugins/client/src/main/java/software/amazon/smithy/java/codegen/client/generators/ClientInterfaceGenerator.java +++ b/codegen/plugins/client/src/main/java/software/amazon/smithy/java/codegen/client/generators/ClientInterfaceGenerator.java @@ -33,6 +33,7 @@ import software.amazon.smithy.java.codegen.CodegenUtils; import software.amazon.smithy.java.codegen.JavaCodegenSettings; import software.amazon.smithy.java.codegen.client.ClientSymbolProperties; +import software.amazon.smithy.java.codegen.client.sections.ClientInterfaceAdditionalMethodsSection; import software.amazon.smithy.java.codegen.integrations.core.GenericTraitInitializer; import software.amazon.smithy.java.codegen.sections.ClassSection; import software.amazon.smithy.java.codegen.sections.OperationSection; @@ -353,6 +354,8 @@ public void run() { } writer.popState(); } + // Add any additional operations from integrations + writer.injectSection(new ClientInterfaceAdditionalMethodsSection(service, isAsync)); writer.popState(); } } diff --git a/codegen/plugins/client/src/main/java/software/amazon/smithy/java/codegen/client/sections/ClientImplAdditionalMethodsSection.java b/codegen/plugins/client/src/main/java/software/amazon/smithy/java/codegen/client/sections/ClientImplAdditionalMethodsSection.java new file mode 100644 index 000000000..5a61e442e --- /dev/null +++ b/codegen/plugins/client/src/main/java/software/amazon/smithy/java/codegen/client/sections/ClientImplAdditionalMethodsSection.java @@ -0,0 +1,17 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.codegen.client.sections; + +import software.amazon.smithy.model.shapes.ServiceShape; +import software.amazon.smithy.utils.CodeSection; + +/** + * This section provides a hook for adding additional methods to the generated client implementation. + * + * @param client Client shape that these methods will be added to. + * @param async true if the client is an async client. + */ +public record ClientImplAdditionalMethodsSection(ServiceShape client, boolean async) implements CodeSection {} diff --git a/codegen/plugins/client/src/main/java/software/amazon/smithy/java/codegen/client/sections/ClientInterfaceAdditionalMethodsSection.java b/codegen/plugins/client/src/main/java/software/amazon/smithy/java/codegen/client/sections/ClientInterfaceAdditionalMethodsSection.java new file mode 100644 index 000000000..a567ff611 --- /dev/null +++ b/codegen/plugins/client/src/main/java/software/amazon/smithy/java/codegen/client/sections/ClientInterfaceAdditionalMethodsSection.java @@ -0,0 +1,17 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.codegen.client.sections; + +import software.amazon.smithy.model.shapes.ServiceShape; +import software.amazon.smithy.utils.CodeSection; + +/** + * This section provides a hook for adding additional methods to the generated client interface. + * + * @param client Client shape that these methods will be added to. + * @param async true if the client is an async client. + */ +public record ClientInterfaceAdditionalMethodsSection(ServiceShape client, boolean async) implements CodeSection {} diff --git a/codegen/plugins/server/src/main/java/software/amazon/smithy/java/codegen/server/DirectedJavaServerCodegen.java b/codegen/plugins/server/src/main/java/software/amazon/smithy/java/codegen/server/DirectedJavaServerCodegen.java index a779f161a..2315729a4 100644 --- a/codegen/plugins/server/src/main/java/software/amazon/smithy/java/codegen/server/DirectedJavaServerCodegen.java +++ b/codegen/plugins/server/src/main/java/software/amazon/smithy/java/codegen/server/DirectedJavaServerCodegen.java @@ -58,7 +58,8 @@ public CodeGenerationContext createContext( directive.settings(), directive.symbolProvider(), directive.fileManifest(), - directive.integrations()); + directive.integrations(), + "server"); } @Override diff --git a/codegen/plugins/types/src/main/java/software/amazon/smithy/java/codegen/types/DirectedJavaTypeCodegen.java b/codegen/plugins/types/src/main/java/software/amazon/smithy/java/codegen/types/DirectedJavaTypeCodegen.java index 8e1564551..ef1659284 100644 --- a/codegen/plugins/types/src/main/java/software/amazon/smithy/java/codegen/types/DirectedJavaTypeCodegen.java +++ b/codegen/plugins/types/src/main/java/software/amazon/smithy/java/codegen/types/DirectedJavaTypeCodegen.java @@ -48,7 +48,8 @@ public CodeGenerationContext createContext( directive.settings(), directive.symbolProvider(), directive.fileManifest(), - directive.integrations()); + directive.integrations(), + "type"); } @Override diff --git a/examples/end-to-end-example/build.gradle.kts b/examples/end-to-end-example/build.gradle.kts index 7fa16b3c3..11a620a3b 100644 --- a/examples/end-to-end-example/build.gradle.kts +++ b/examples/end-to-end-example/build.gradle.kts @@ -9,6 +9,7 @@ dependencies { api(project(":server:server-core")) implementation(project(":server:server-netty")) api(project(":aws:server:aws-server-restjson")) + implementation(libs.smithy.waiters) // Client dependencies api(project(":aws:client:aws-client-restjson")) @@ -25,6 +26,8 @@ dependencies { implementation(project(":examples:middleware-example:client-integration")) // TODO: Add example server middleware once applicable + smithyBuild(project(":codegen:integrations:waiters-codegen")) + implementation(project(":client:waiters")) } jmh { diff --git a/examples/end-to-end-example/model/order.smithy b/examples/end-to-end-example/model/order.smithy index 9d8489e7b..31ab5fd00 100644 --- a/examples/end-to-end-example/model/order.smithy +++ b/examples/end-to-end-example/model/order.smithy @@ -4,6 +4,7 @@ namespace com.example use com.shared.types#OrderStatus use com.shared.types#Uuid +use smithy.waiters#waitable /// An Order resource, which has an id and describes an order by the type of coffee /// and the order's status @@ -41,6 +42,25 @@ operation CreateOrder { } /// Retrieve an order +@waitable( + OrderExists: { + documentation: "Wait until the order exists" + acceptors: [ + { + state: "success" + matcher: { success: true } + } + { + state: "success" + matcher: { errorType: OtherError } + } + { + state: "retry" + matcher: { errorType: OrderNotFound } + } + ] + } +) @readonly @http(method: "GET", uri: "/order/{id}") operation GetOrder { @@ -63,6 +83,7 @@ operation GetOrder { errors: [ OrderNotFound + OtherError ] } @@ -74,3 +95,9 @@ structure OrderNotFound { message: String orderId: Uuid } + +@httpError(405) +@error("client") +structure OtherError { + message: String +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6dc203679..f3f1cfb74 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -26,6 +26,7 @@ smithy-aws-protocol-tests = { module = "software.amazon.smithy:smithy-aws-protoc smithy-protocol-tests = { module = "software.amazon.smithy:smithy-protocol-tests", version.ref = "smithy" } smithy-validation-model = { module = "software.amazon.smithy:smithy-validation-model", version.ref = "smithy" } smithy-jmespath = { module = "software.amazon.smithy:smithy-jmespath", version.ref = "smithy" } +smithy-waiters = { module = "software.amazon.smithy:smithy-waiters", version.ref = "smithy" } # AWS SDK for Java V2 adapters. aws-sdk-retries-spi = {module = "software.amazon.awssdk:retries-spi", version.ref = "aws-sdk"} diff --git a/settings.gradle.kts b/settings.gradle.kts index 067fa5e29..ec26b239e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -26,6 +26,7 @@ include(":client:client-http-binding") include(":client:client-rpcv2-cbor") include(":client:dynamic-client") include(":client:mock-client-plugin") +include(":client:waiters") // Server include(":server:server-api") @@ -39,6 +40,8 @@ include(":codegen:plugins") include(":codegen:plugins:client") include(":codegen:plugins:server") include(":codegen:plugins:types") +include(":codegen:integrations:waiters-codegen") + // Utilities include(":protocol-test-harness")