From fda9d1c156da88a37f7f3b9d4f0cdaaac7e179d7 Mon Sep 17 00:00:00 2001 From: Michael Dowling Date: Fri, 30 Oct 2020 15:14:54 -0700 Subject: [PATCH 1/5] Add smithy-jmespath for parsing JMESPath We will be using JMESPath in things like waiters, so we need a JMESPath parser that has no dependencies, exposes a rich AST that can be used in code generation, and performs static analysis of expressions. --- settings.gradle | 1 + smithy-jmespath/README.md | 7 + smithy-jmespath/build.gradle | 21 + .../smithy/jmespath/ExpressionProblem.java | 86 +++ .../smithy/jmespath/ExpressionVisitor.java | 79 +++ .../smithy/jmespath/FunctionDefinition.java | 89 +++ .../smithy/jmespath/JmespathException.java | 29 + .../smithy/jmespath/JmespathExpression.java | 95 +++ .../amazon/smithy/jmespath/Lexer.java | 621 ++++++++++++++++ .../amazon/smithy/jmespath/LinterResult.java | 54 ++ .../amazon/smithy/jmespath/Parser.java | 403 +++++++++++ .../amazon/smithy/jmespath/RuntimeType.java | 34 + .../amazon/smithy/jmespath/Token.java | 49 ++ .../amazon/smithy/jmespath/TokenIterator.java | 127 ++++ .../amazon/smithy/jmespath/TokenType.java | 67 ++ .../amazon/smithy/jmespath/TypeChecker.java | 482 +++++++++++++ .../smithy/jmespath/ast/AndExpression.java | 39 + .../smithy/jmespath/ast/BinaryExpression.java | 73 ++ .../smithy/jmespath/ast/ComparatorType.java | 40 ++ .../jmespath/ast/ComparisonExpression.java | 83 +++ .../jmespath/ast/CurrentExpression.java | 53 ++ .../ast/ExpressionReferenceExpression.java | 73 ++ .../smithy/jmespath/ast/FieldExpression.java | 72 ++ .../ast/FilterProjectionExpression.java | 91 +++ .../jmespath/ast/FlattenExpression.java | 72 ++ .../jmespath/ast/FunctionExpression.java | 84 +++ .../smithy/jmespath/ast/IndexExpression.java | 73 ++ .../jmespath/ast/LiteralExpression.java | 345 +++++++++ .../ast/MultiSelectHashExpression.java | 73 ++ .../ast/MultiSelectListExpression.java | 73 ++ .../smithy/jmespath/ast/NotExpression.java | 72 ++ .../ast/ObjectProjectionExpression.java | 38 + .../smithy/jmespath/ast/OrExpression.java | 38 + .../jmespath/ast/ProjectionExpression.java | 39 + .../smithy/jmespath/ast/SliceExpression.java | 83 +++ .../smithy/jmespath/ast/Subexpression.java | 38 + .../amazon/smithy/jmespath/LexerTest.java | 665 ++++++++++++++++++ .../amazon/smithy/jmespath/ParserTest.java | 386 ++++++++++ .../amazon/smithy/jmespath/RunnerTest.java | 60 ++ .../smithy/jmespath/TokenIteratorTest.java | 159 +++++ .../smithy/jmespath/TypeCheckerTest.java | 373 ++++++++++ .../jmespath/ast/LiteralExpressionTest.java | 161 +++++ .../software/amazon/smithy/jmespath/invalid | 101 +++ .../software/amazon/smithy/jmespath/valid | 572 +++++++++++++++ 44 files changed, 6273 insertions(+) create mode 100644 smithy-jmespath/README.md create mode 100644 smithy-jmespath/build.gradle create mode 100644 smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ExpressionProblem.java create mode 100644 smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ExpressionVisitor.java create mode 100644 smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/FunctionDefinition.java create mode 100644 smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/JmespathException.java create mode 100644 smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/JmespathExpression.java create mode 100644 smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/Lexer.java create mode 100644 smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/LinterResult.java create mode 100644 smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/Parser.java create mode 100644 smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/RuntimeType.java create mode 100644 smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/Token.java create mode 100644 smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/TokenIterator.java create mode 100644 smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/TokenType.java create mode 100644 smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/TypeChecker.java create mode 100644 smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/AndExpression.java create mode 100644 smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/BinaryExpression.java create mode 100644 smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/ComparatorType.java create mode 100644 smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/ComparisonExpression.java create mode 100644 smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/CurrentExpression.java create mode 100644 smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/ExpressionReferenceExpression.java create mode 100644 smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/FieldExpression.java create mode 100644 smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/FilterProjectionExpression.java create mode 100644 smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/FlattenExpression.java create mode 100644 smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/FunctionExpression.java create mode 100644 smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/IndexExpression.java create mode 100644 smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/LiteralExpression.java create mode 100644 smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/MultiSelectHashExpression.java create mode 100644 smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/MultiSelectListExpression.java create mode 100644 smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/NotExpression.java create mode 100644 smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/ObjectProjectionExpression.java create mode 100644 smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/OrExpression.java create mode 100644 smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/ProjectionExpression.java create mode 100644 smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/SliceExpression.java create mode 100644 smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/Subexpression.java create mode 100644 smithy-jmespath/src/test/java/software/amazon/smithy/jmespath/LexerTest.java create mode 100644 smithy-jmespath/src/test/java/software/amazon/smithy/jmespath/ParserTest.java create mode 100644 smithy-jmespath/src/test/java/software/amazon/smithy/jmespath/RunnerTest.java create mode 100644 smithy-jmespath/src/test/java/software/amazon/smithy/jmespath/TokenIteratorTest.java create mode 100644 smithy-jmespath/src/test/java/software/amazon/smithy/jmespath/TypeCheckerTest.java create mode 100644 smithy-jmespath/src/test/java/software/amazon/smithy/jmespath/ast/LiteralExpressionTest.java create mode 100644 smithy-jmespath/src/test/resources/software/amazon/smithy/jmespath/invalid create mode 100644 smithy-jmespath/src/test/resources/software/amazon/smithy/jmespath/valid diff --git a/settings.gradle b/settings.gradle index aa50eee3e48..ceeef54aaf9 100644 --- a/settings.gradle +++ b/settings.gradle @@ -22,3 +22,4 @@ include ":smithy-jsonschema" include ":smithy-openapi" include ":smithy-utils" include ":smithy-protocol-test-traits" +include ':smithy-jmespath' diff --git a/smithy-jmespath/README.md b/smithy-jmespath/README.md new file mode 100644 index 00000000000..56ba055c736 --- /dev/null +++ b/smithy-jmespath/README.md @@ -0,0 +1,7 @@ +# Smithy JMESPath + +This is an implementation of a [JMESPath](https://jmespath.org/) parser +written in Java. It's not intended to be used at runtime and does not include +an interpreter. It doesn't implement functions. Its goal is to parser +JMESPath expressions, perform static analysis on them, and provide an AST +that can be used for code generation. diff --git a/smithy-jmespath/build.gradle b/smithy-jmespath/build.gradle new file mode 100644 index 00000000000..108157efd1d --- /dev/null +++ b/smithy-jmespath/build.gradle @@ -0,0 +1,21 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +description = "A standalone JMESPath parser" + +ext { + displayName = "Smithy :: JMESPath" + moduleName = "software.amazon.smithy.jmespath" +} diff --git a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ExpressionProblem.java b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ExpressionProblem.java new file mode 100644 index 00000000000..a13998dd048 --- /dev/null +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ExpressionProblem.java @@ -0,0 +1,86 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.smithy.jmespath; + +import java.util.Objects; + +/** + * Represents a problem detected by static analysis. + */ +public final class ExpressionProblem implements Comparable { + + /** + * The severity of the problem. + */ + public enum Severity { + /** The problem is an unrecoverable error. */ + ERROR, + + /** The problem is a warning that you might be able to ignore depending on the input. */ + DANGER, + + /** The problem points out a potential issue that may be intentional. */ + WARNING + } + + /** The description of the problem. */ + public final String message; + + /** The line where the problem occurred. */ + public final int line; + + /** The column where the problem occurred. */ + public final int column; + + /** The severity of the problem. */ + public final Severity severity; + + ExpressionProblem(Severity severity, int line, int column, String message) { + this.severity = severity; + this.line = line; + this.column = column; + this.message = message; + } + + @Override + public String toString() { + return "[" + severity + "] " + message + " (" + line + ":" + column + ")"; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } else if (!(o instanceof ExpressionProblem)) { + return false; + } + ExpressionProblem problem = (ExpressionProblem) o; + return severity == problem.severity + && line == problem.line + && column == problem.column + && message.equals(problem.message); + } + + @Override + public int hashCode() { + return Objects.hash(severity, message, line, column); + } + + @Override + public int compareTo(ExpressionProblem o) { + return toString().compareTo(o.toString()); + } +} diff --git a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ExpressionVisitor.java b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ExpressionVisitor.java new file mode 100644 index 00000000000..2b7cff93a63 --- /dev/null +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ExpressionVisitor.java @@ -0,0 +1,79 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.smithy.jmespath; + +import software.amazon.smithy.jmespath.ast.AndExpression; +import software.amazon.smithy.jmespath.ast.ComparisonExpression; +import software.amazon.smithy.jmespath.ast.CurrentExpression; +import software.amazon.smithy.jmespath.ast.ExpressionReferenceExpression; +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; + +/** + * Visits each type of AST node. + * + * @param Value returned from the visitor. + */ +public interface ExpressionVisitor { + + T visitComparison(ComparisonExpression expression); + + T visitCurrentNode(CurrentExpression expression); + + T visitExpressionReference(ExpressionReferenceExpression expression); + + T visitFlatten(FlattenExpression expression); + + T visitFunction(FunctionExpression expression); + + T visitField(FieldExpression expression); + + T visitIndex(IndexExpression expression); + + T visitLiteral(LiteralExpression expression); + + T visitMultiSelectList(MultiSelectListExpression expression); + + T visitMultiSelectHash(MultiSelectHashExpression expression); + + T visitAnd(AndExpression expression); + + T visitOr(OrExpression expression); + + T visitNot(NotExpression expression); + + T visitProjection(ProjectionExpression expression); + + T visitFilterProjection(FilterProjectionExpression expression); + + T visitObjectProjection(ObjectProjectionExpression expression); + + T visitSlice(SliceExpression expression); + + T visitSubexpression(Subexpression expression); +} diff --git a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/FunctionDefinition.java b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/FunctionDefinition.java new file mode 100644 index 00000000000..c8349eabd31 --- /dev/null +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/FunctionDefinition.java @@ -0,0 +1,89 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.smithy.jmespath; + +import java.util.Arrays; +import java.util.List; +import software.amazon.smithy.jmespath.ast.LiteralExpression; + +final class FunctionDefinition { + + @FunctionalInterface + interface ArgValidator { + String validate(LiteralExpression argument); + } + + final LiteralExpression returnValue; + final List arguments; + final ArgValidator variadic; + + FunctionDefinition(LiteralExpression returnValue, ArgValidator... arguments) { + this(returnValue, Arrays.asList(arguments), null); + } + + FunctionDefinition(LiteralExpression returnValue, List arguments, ArgValidator variadic) { + this.returnValue = returnValue; + this.arguments = arguments; + this.variadic = variadic; + } + + static ArgValidator isType(RuntimeType type) { + return arg -> { + if (type == RuntimeType.ANY || arg.getType() == RuntimeType.ANY) { + return null; + } else if (arg.getType() == type) { + return null; + } else { + return "Expected argument to be " + type + ", but found " + arg.getType(); + } + }; + } + + static ArgValidator listOfType(RuntimeType type) { + return arg -> { + if (type == RuntimeType.ANY || arg.getType() == RuntimeType.ANY) { + return null; + } else if (arg.getType() == RuntimeType.ARRAY) { + List values = arg.asArrayValue(); + for (int i = 0; i < values.size(); i++) { + LiteralExpression element = LiteralExpression.from(values.get(i)); + if (element.getType() != type) { + return "Expected an array of " + type + ", but found " + element.getType() + " at index " + i; + } + } + } else { + return "Expected argument to be an array, but found " + arg.getType(); + } + return null; + }; + } + + static ArgValidator oneOf(RuntimeType... types) { + return arg -> { + if (arg.getType() == RuntimeType.ANY) { + return null; + } + + for (RuntimeType type : types) { + if (arg.getType() == type || type == RuntimeType.ANY) { + return null; + } + } + + return "Expected one of " + Arrays.toString(types) + ", but found " + arg.getType(); + }; + } +} diff --git a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/JmespathException.java b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/JmespathException.java new file mode 100644 index 00000000000..59e67b4f549 --- /dev/null +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/JmespathException.java @@ -0,0 +1,29 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.smithy.jmespath; + +/** + * Thrown when any JMESPath error occurs. + */ +public class JmespathException extends RuntimeException { + public JmespathException(String message) { + super(message); + } + + public JmespathException(String message, Throwable previous) { + super(message, previous); + } +} diff --git a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/JmespathExpression.java b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/JmespathExpression.java new file mode 100644 index 00000000000..d858e0bbbc9 --- /dev/null +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/JmespathExpression.java @@ -0,0 +1,95 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.smithy.jmespath; + +import java.util.Set; +import java.util.TreeSet; +import software.amazon.smithy.jmespath.ast.LiteralExpression; + +/** + * Represents a JMESPath AST node. + */ +public abstract class JmespathExpression { + + private final int line; + private final int column; + + protected JmespathExpression(int line, int column) { + this.line = line; + this.column = column; + } + + /** + * Parse a JMESPath expression. + * + * @param text Expression to parse. + * @return Returns the parsed expression. + * @throws JmespathException if the expression is invalid. + */ + public static JmespathExpression parse(String text) { + return Parser.parse(text); + } + + /** + * Get the approximate line where the node was defined. + * + * @return Returns the line. + */ + public final int getLine() { + return line; + } + + /** + * Get the approximate column where the node was defined. + * + * @return Returns the column. + */ + public final int getColumn() { + return column; + } + + /** + * Visits a node using a double-dispatch visitor. + * + * @param visitor Visitor to accept on the node. + * @param Type of value the visitor returns. + * @return Returns the result of applying the visitor. + */ + public abstract T accept(ExpressionVisitor visitor); + + /** + * Lint the expression using static analysis using "any" as the + * current node. + * + * @return Returns the linter result. + */ + public LinterResult lint() { + return lint(LiteralExpression.ANY); + } + + /** + * Lint the expression using static analysis. + * + * @param currentNode The value to set as the current node. + * @return Returns the problems that were detected. + */ + public LinterResult lint(LiteralExpression currentNode) { + Set problems = new TreeSet<>(); + TypeChecker typeChecker = new TypeChecker(currentNode, problems); + LiteralExpression result = this.accept(typeChecker); + return new LinterResult(result.getType(), problems); + } +} diff --git a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/Lexer.java b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/Lexer.java new file mode 100644 index 00000000000..244ba9b039f --- /dev/null +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/Lexer.java @@ -0,0 +1,621 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.smithy.jmespath; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Predicate; +import software.amazon.smithy.jmespath.ast.LiteralExpression; + +final class Lexer { + + private static final int MAX_NESTING_LEVEL = 50; + + private final String expression; + private final int length; + private int position = 0; + private int line = 1; + private int column = 1; + private int nestingLevel = 0; + private final List tokens = new ArrayList<>(); + private boolean currentlyParsingLiteral; + + private Lexer(String expression) { + this.expression = Objects.requireNonNull(expression, "expression must not be null"); + this.length = expression.length(); + } + + static TokenIterator tokenize(String expression) { + return new Lexer(expression).doTokenize(); + } + + TokenIterator doTokenize() { + while (!eof()) { + char c = peek(); + + if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_') { + tokens.add(parseIdentifier()); + continue; + } + + if (c == '-' || (c >= '0' && c <= '9')) { + tokens.add(parseNumber()); + continue; + } + + switch (c) { + case '.': + tokens.add(new Token(TokenType.DOT, null, line, column)); + skip(); + break; + case '[': + tokens.add(parseLbracket()); + break; + case '*': + tokens.add(new Token(TokenType.STAR, null, line, column)); + skip(); + break; + case '|': + tokens.add(parseAlternatives('|', TokenType.OR, TokenType.PIPE)); + break; + case '@': + tokens.add(new Token(TokenType.CURRENT, null, line, column)); + skip(); + break; + case ']': + tokens.add(new Token(TokenType.RBRACKET, null, line, column)); + skip(); + break; + case '{': + tokens.add(new Token(TokenType.LBRACE, null, line, column)); + skip(); + break; + case '}': + tokens.add(new Token(TokenType.RBRACE, null, line, column)); + skip(); + break; + case '&': + tokens.add(parseAlternatives('&', TokenType.AND, TokenType.EXPREF)); + break; + case '(': + tokens.add(new Token(TokenType.LPAREN, null, line, column)); + skip(); + break; + case ')': + tokens.add(new Token(TokenType.RPAREN, null, line, column)); + skip(); + break; + case ',': + tokens.add(new Token(TokenType.COMMA, null, line, column)); + skip(); + break; + case ':': + tokens.add(new Token(TokenType.COLON, null, line, column)); + skip(); + break; + case '"': + tokens.add(parseString()); + break; + case '\'': + tokens.add(parseRawStringLiteral()); + break; + case '`': + tokens.add(parseLiteral()); + break; + case '=': + tokens.add(parseEquals()); + break; + case '>': + tokens.add(parseAlternatives('=', TokenType.GREATER_THAN_EQUAL, TokenType.GREATER_THAN)); + break; + case '<': + tokens.add(parseAlternatives('=', TokenType.LESS_THAN_EQUAL, TokenType.LESS_THAN)); + break; + case '!': + tokens.add(parseAlternatives('=', TokenType.NOT_EQUAL, TokenType.NOT)); + break; + case ' ': + case '\n': + case '\r': + case '\t': + skip(); + break; + default: + throw syntax("Unexpected syntax: " + peekSingleCharForMessage()); + } + } + + tokens.add(new Token(TokenType.EOF, null, line, column)); + return new TokenIterator(tokens); + } + + private boolean eof() { + return position >= length; + } + + private char peek() { + return peek(0); + } + + private char peek(int offset) { + int target = position + offset; + if (target >= length || target < 0) { + return Character.MIN_VALUE; + } + + return expression.charAt(target); + } + + private char expect(char token) { + if (peek() == token) { + skip(); + return token; + } + + throw syntax(String.format("Expected: '%s', but found '%s'", token, peekSingleCharForMessage())); + } + + private String peekSingleCharForMessage() { + char peek = peek(); + return peek == Character.MIN_VALUE ? "[EOF]" : String.valueOf(peek); + } + + private char expect(char... tokens) { + for (char token : tokens) { + if (peek() == token) { + skip(); + return token; + } + } + + StringBuilder message = new StringBuilder("Found '") + .append(peekSingleCharForMessage()) + .append("', but expected one of the following tokens:"); + for (char c : tokens) { + message.append(' ').append('\'').append(c).append('\''); + } + + throw syntax(message.toString()); + } + + private JmespathException syntax(String message) { + return new JmespathException("Syntax error at line " + line + " column " + column + ": " + message); + } + + private void skip() { + if (eof()) { + return; + } + + switch (expression.charAt(position)) { + case '\r': + if (peek(1) == '\n') { + position++; + } + line++; + column = 1; + break; + case '\n': + line++; + column = 1; + break; + default: + column++; + } + + position++; + } + + /** + * Gets a slice of the expression starting from the given 0-based + * character position, read all the way through to the current + * position of the parser. + * + * @param start Position to slice from, ending at the current position. + * @return Returns the slice of the expression from {@code start} to {@link #position}. + */ + private String sliceFrom(int start) { + return expression.substring(start, position); + } + + private int consumeUntilNoLongerMatches(Predicate predicate) { + int startPosition = position; + while (!eof()) { + char peekedChar = peek(); + if (!predicate.test(peekedChar)) { + break; + } + skip(); + } + + return position - startPosition; + } + + private void increaseNestingLevel() { + nestingLevel++; + + if (nestingLevel > MAX_NESTING_LEVEL) { + throw syntax("Parser exceeded the maximum allowed depth of " + MAX_NESTING_LEVEL); + } + } + + private void decreaseNestingLevel() { + nestingLevel--; + } + + private Token parseAlternatives(char next, TokenType first, TokenType second) { + int currentLine = line; + int currentColumn = column; + skip(); + if (peek() == next) { + skip(); + return new Token(first, null, currentLine, currentColumn); + } else { + return new Token(second, null, currentLine, currentColumn); + } + } + + private Token parseEquals() { + int currentLine = line; + int currentColumn = column; + skip(); + expect('='); + return new Token(TokenType.EQUAL, null, currentLine, currentColumn); + } + + private Token parseIdentifier() { + int start = position; + int currentLine = line; + int currentColumn = column; + consumeUntilNoLongerMatches(this::isIdentifierCharacter); + LiteralExpression literalNode = new LiteralExpression(sliceFrom(start), currentLine, currentColumn); + return new Token(TokenType.IDENTIFIER, literalNode, currentLine, currentColumn); + } + + private boolean isIdentifierCharacter(char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_' || (c >= '0' && c <= '9'); + } + + private Token parseString() { + int currentLine = line; + int currentColumn = column; + expect('"'); + String value = consumeInsideString(); + return new Token(TokenType.IDENTIFIER, + new LiteralExpression(value, currentLine, currentColumn), currentLine, currentColumn); + } + + private String consumeInsideString() { + StringBuilder builder = new StringBuilder(); + + loop: while (!eof()) { + switch (peek()) { + case '"': + skip(); + return builder.toString(); + case '\\': + skip(); + switch (peek()) { + case '"': + builder.append('"'); + skip(); + break; + case 'n': + builder.append('\n'); + skip(); + break; + case 't': + builder.append('\t'); + skip(); + break; + case 'r': + builder.append('\r'); + skip(); + break; + case 'f': + builder.append('\f'); + skip(); + break; + case 'b': + builder.append('\b'); + skip(); + break; + case '/': + builder.append('/'); + skip(); + break; + case '\\': + builder.append('\\'); + skip(); + break; + case 'u': + // Read \ u XXXX + skip(); + int unicode = 0; + for (int i = 0; i < 4; i++) { + char c = peek(); + skip(); + if (c >= '0' && c <= '9') { + unicode = (unicode << 4) | (c - '0'); + } else if (c >= 'a' && c <= 'f') { + unicode = (unicode << 4) | (10 + c - 'a'); + } else if (c >= 'A' && c <= 'F') { + unicode = (unicode << 4) | (10 + c - 'A'); + } else { + throw syntax("Invalid unicode escape character: `" + c + "`"); + } + } + builder.append((char) unicode); + break; + case '`': + // Ticks can be escaped when parsing literals. + if (currentlyParsingLiteral) { + builder.append('`'); + skip(); + break; + } + // fall-through. + default: + throw syntax("Invalid escape: " + peek()); + } + break; + case '`': + // If parsing a literal and an unescaped "`" is encountered, + // then the literal was erroneously closed while parsing a string. + if (currentlyParsingLiteral) { + skip(); + break loop; + } // fall-through + default: + builder.append(peek()); + skip(); + break; + } + } + + throw syntax("Unclosed quotes"); + } + + private Token parseRawStringLiteral() { + int currentLine = line; + int currentColumn = column; + expect('\''); + + StringBuilder builder = new StringBuilder(); + while (!eof()) { + if (peek() == '\\') { + skip(); + if (peek() == '\'') { + skip(); + builder.append('\''); + } else { + if (peek() == '\\') { + skip(); + } + builder.append('\\'); + } + } else if (peek() == '\'') { + skip(); + String result = builder.toString(); + return new Token(TokenType.LITERAL, + new LiteralExpression(result, currentLine, currentColumn), + currentLine, currentColumn); + } else { + builder.append(peek()); + skip(); + } + } + + throw syntax("Unclosed raw string: " + builder); + } + + private static boolean isDigit(char c) { + return c >= '0' && c <= '9'; + } + + private Token parseNumber() { + int start = position; + int currentLine = line; + int currentColumn = column; + + int startPosition = position; + char current = peek(); + + if (current == '-') { + skip(); + if (!isDigit(peek())) { + throw syntax(createInvalidNumberString(startPosition, "'-' must be followed by a digit")); + } + } + + consumeUntilNoLongerMatches(Lexer::isDigit); + + // Consume decimals. + char peek = peek(); + if (peek == '.') { + skip(); + if (consumeUntilNoLongerMatches(Lexer::isDigit) == 0) { + throw syntax(createInvalidNumberString(startPosition, "'.' must be followed by a digit")); + } + } + + // Consume scientific notation. + peek = peek(); + if (peek == 'e' || peek == 'E') { + skip(); + peek = peek(); + if (peek == '+' || peek == '-') { + skip(); + } + if (consumeUntilNoLongerMatches(Lexer::isDigit) == 0) { + throw syntax(createInvalidNumberString(startPosition, "'e', '+', and '-' must be followed by a digit")); + } + } + + String lexeme = sliceFrom(start); + + try { + double number = Double.parseDouble(lexeme); + LiteralExpression node = new LiteralExpression(number, currentLine, currentColumn); + return new Token(TokenType.NUMBER, node, currentLine, currentColumn); + } catch (NumberFormatException e) { + throw syntax("Invalid number syntax: " + lexeme); + } + } + + private String createInvalidNumberString(int startPosition, String message) { + String lexeme = sliceFrom(startPosition); + return String.format("Invalid number '%s': %s", lexeme, message); + } + + private Token parseLbracket() { + int currentLine = line; + int currentColumn = column; + skip(); + switch (peek()) { + case ']': + skip(); + return new Token(TokenType.FLATTEN, null, currentLine, currentColumn); + case '?': + skip(); + return new Token(TokenType.FILTER, null, currentLine, currentColumn); + default: + return new Token(TokenType.LBRACKET, null, currentLine, currentColumn); + } + } + + private Token parseLiteral() { + int currentLine = line; + int currentColumn = column; + currentlyParsingLiteral = true; + expect('`'); + ws(); + Object value = parseJsonValue(); + ws(); + expect('`'); + currentlyParsingLiteral = false; + LiteralExpression expression = new LiteralExpression(value, currentLine, currentColumn); + return new Token(TokenType.LITERAL, expression, currentLine, currentColumn); + } + + private Object parseJsonValue() { + ws(); + switch (expect('\"', '{', '[', 't', 'f', 'n', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '-')) { + case 't': + expect('r'); + expect('u'); + expect('e'); + return true; + case 'f': + expect('a'); + expect('l'); + expect('s'); + expect('e'); + return false; + case 'n': + expect('u'); + expect('l'); + expect('l'); + return null; + case '"': + // Backtrack for positioning. + position--; + column--; + return parseString().value.asStringValue(); + case '{': + return parseJsonObject(); + case '[': + return parseJsonArray(); + default: // - | 0-9 + // Backtrack. + position--; + column--; + return parseNumber().value.asNumberValue(); + } + } + + private Object parseJsonArray() { + increaseNestingLevel(); + List values = new ArrayList<>(); + ws(); + + if (peek() == ']') { + skip(); + decreaseNestingLevel(); + return values; + } + + while (!eof() && peek() != '`') { + values.add(parseJsonValue()); + ws(); + if (expect(',', ']') == ',') { + ws(); + } else { + decreaseNestingLevel(); + return values; + } + } + + throw syntax("Unclosed JSON array"); + } + + private Object parseJsonObject() { + increaseNestingLevel(); + Map values = new LinkedHashMap<>(); + ws(); + + if (peek() == '}') { + skip(); + decreaseNestingLevel(); + return values; + } + + while (!eof() && peek() != '`') { + String key = parseString().value.asStringValue(); + ws(); + expect(':'); + ws(); + values.put(key, parseJsonValue()); + ws(); + if (expect(',', '}') == ',') { + ws(); + } else { + decreaseNestingLevel(); + return values; + } + } + + throw syntax("Unclosed JSON object"); + } + + private void ws() { + while (!eof()) { + switch (peek()) { + case ' ': + case '\t': + case '\r': + case '\n': + skip(); + break; + default: + return; + } + } + } +} diff --git a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/LinterResult.java b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/LinterResult.java new file mode 100644 index 00000000000..6a9acdbc2b4 --- /dev/null +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/LinterResult.java @@ -0,0 +1,54 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.smithy.jmespath; + +import java.util.Objects; +import java.util.Set; + +public final class LinterResult { + + public final RuntimeType returnType; + public final Set problems; + + public LinterResult(RuntimeType returnType, Set problems) { + this.returnType = returnType; + this.problems = problems; + } + + public RuntimeType getReturnType() { + return returnType; + } + + public Set getProblems() { + return problems; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } else if (!(o instanceof LinterResult)) { + return false; + } + LinterResult that = (LinterResult) o; + return returnType == that.returnType && problems.equals(that.problems); + } + + @Override + public int hashCode() { + return Objects.hash(returnType, problems); + } +} diff --git a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/Parser.java b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/Parser.java new file mode 100644 index 00000000000..7ef421d4d8f --- /dev/null +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/Parser.java @@ -0,0 +1,403 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.smithy.jmespath; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import software.amazon.smithy.jmespath.ast.AndExpression; +import software.amazon.smithy.jmespath.ast.ComparatorType; +import software.amazon.smithy.jmespath.ast.ComparisonExpression; +import software.amazon.smithy.jmespath.ast.CurrentExpression; +import software.amazon.smithy.jmespath.ast.ExpressionReferenceExpression; +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; + +/** + * A top-down operator precedence parser (aka Pratt parser) for JMESPath. + */ +final class Parser { + + /** The maximum binding power for a token that can stop a projection. */ + private static final int PROJECTION_STOP = 10; + + /** Tokens that can start an expression. */ + private static final TokenType[] NUD_TOKENS = { + TokenType.CURRENT, + TokenType.IDENTIFIER, + TokenType.LITERAL, + TokenType.STAR, + TokenType.LBRACE, + TokenType.LBRACKET, + TokenType.FLATTEN, + TokenType.EXPREF, + TokenType.NOT, + TokenType.FILTER, + TokenType.LPAREN + }; + + /** Tokens that can follow led tokens. */ + private static final TokenType[] LED_TOKENS = { + TokenType.DOT, + TokenType.LBRACKET, + TokenType.OR, + TokenType.AND, + TokenType.PIPE, + TokenType.LPAREN, + TokenType.FLATTEN, + TokenType.FILTER, + TokenType.EQUAL, + TokenType.NOT_EQUAL, + TokenType.GREATER_THAN, + TokenType.GREATER_THAN_EQUAL, + TokenType.LESS_THAN, + TokenType.LESS_THAN_EQUAL + }; + + private final String expression; + private final TokenIterator iterator; + + private Parser(String expression) { + this.expression = expression; + iterator = Lexer.tokenize(expression); + } + + static JmespathExpression parse(String expression) { + Parser parser = new Parser(expression); + JmespathExpression result = parser.expression(0); + parser.iterator.expect(TokenType.EOF); + return result; + } + + private JmespathExpression expression(int rbp) { + JmespathExpression left = nud(); + while (iterator.hasNext() && rbp < iterator.peek().type.lbp) { + left = led(left); + } + return left; + } + + private JmespathExpression nud() { + Token token = iterator.expect(NUD_TOKENS); + switch (token.type) { + case CURRENT: // Example: @ + return new CurrentExpression(token.line, token.column); + case IDENTIFIER: // Example: foo + // For example, "foo(" starts a function expression. + if (iterator.peek().type == TokenType.LPAREN) { + iterator.expect(TokenType.LPAREN); + List arguments = parseList(TokenType.RPAREN); + return new FunctionExpression(token.value.asStringValue(), arguments, token.line, token.column); + } else { + return new FieldExpression(token.value.asStringValue(), token.line, token.column); + } + case STAR: // Example: * + return parseWildcardObject(new CurrentExpression(token.line, token.column)); + case LITERAL: // Example: `true` + return new LiteralExpression(token.value.getValue(), token.line, token.column); + case LBRACKET: // Example: [1] + return parseNudLbracket(); + case LBRACE: // Example: {foo: bar} + return parseNudLbrace(); + case FLATTEN: // Example: [].bar + return parseFlatten(new CurrentExpression(token.line, token.column)); + case EXPREF: // Example: sort_by(@, &foo) + JmespathExpression expressionRef = expression(token.type.lbp); + return new ExpressionReferenceExpression(expressionRef, token.line, token.column); + case NOT: // Example: !foo + JmespathExpression notNode = expression(token.type.lbp); + return new NotExpression(notNode, token.line, token.column); + case FILTER: // Example: [?foo == bar] + return parseFilter(new CurrentExpression(token.line, token.column)); + case LPAREN: // Example (foo) + JmespathExpression insideParens = expression(0); + iterator.expect(TokenType.RPAREN); + return insideParens; + default: + throw iterator.syntax("Invalid nud token: " + token); + } + } + + private JmespathExpression led(JmespathExpression left) { + Token token = iterator.expect(LED_TOKENS); + + switch (token.type) { + case DOT: + // For example, "foo.bar" + if (iterator.peek().type == TokenType.STAR) { + // "Example: foo.*". This is mostly an optimization of the + // generated AST to not need a subexpression to contain the + // projection. + iterator.expect(TokenType.STAR); // skip the "*". + return parseWildcardObject(left); + } else { + // "foo.*", "foo.bar", "foo.[bar]", "foo.length(@)", etc. + JmespathExpression dotRhs = parseDotRhs(TokenType.DOT.lbp); + return new Subexpression(left, dotRhs, token.line, token.column); + } + case FLATTEN: // Example: a[].b + return parseFlatten(left); + case OR: // Example: a || b + return new OrExpression(left, expression(token.type.lbp), token.line, token.column); + case AND: // Example: a && b + return new AndExpression(left, expression(token.type.lbp), token.line, token.column); + case PIPE: // Example: a | b + return new Subexpression(left, expression(token.type.lbp), token.line, token.column); + case FILTER: // Example: a[?foo == bar] + return parseFilter(left); + case LBRACKET: + Token bracketToken = iterator.expectPeek(TokenType.NUMBER, TokenType.COLON, TokenType.STAR); + if (bracketToken.type == TokenType.STAR) { + // For example, "foo[*]" + return parseWildcardIndex(left); + } else { + // For example, "foo[::1]", "foo[1]" + return new Subexpression(left, parseIndex(), token.line, token.column); + } + case EQUAL: // Example: a == b + return parseComparator(ComparatorType.EQUAL, left); + case NOT_EQUAL: // Example: a != b + return parseComparator(ComparatorType.NOT_EQUAL, left); + case GREATER_THAN: // Example: a > b + return parseComparator(ComparatorType.GREATER_THAN, left); + case GREATER_THAN_EQUAL: // Example: a >= b + return parseComparator(ComparatorType.GREATER_THAN_EQUAL, left); + case LESS_THAN: // Example: a < b + return parseComparator(ComparatorType.LESS_THAN, left); + case LESS_THAN_EQUAL: // Example: a <= b + return parseComparator(ComparatorType.LESS_THAN_EQUAL, left); + default: + throw iterator.syntax("Invalid led token: " + token); + } + } + + private JmespathExpression parseNudLbracket() { + switch (iterator.expectNotEof().type) { + case NUMBER: + case COLON: + // An index is parsed when things like '[1' or '[1:' are encountered. + return parseIndex(); + case STAR: + if (iterator.peek(1).type == TokenType.RBRACKET) { + // A led '[*]' sets the left-hand side of the projection to the left node, + // but a nud '[*]' uses the current node as the left node. + return parseWildcardIndex(new CurrentExpression(iterator.line(), iterator.column())); + } // fall-through + default: + // Everything else is a multi-select list that creates an array of values. + return parseMultiList(); + } + } + + // Parses [0], [::-1], [0:-1], [0:1], etc. + private JmespathExpression parseIndex() { + int line = iterator.line(); + int column = iterator.column(); + Integer[] parts = new Integer[]{null, null, 1}; // start, stop, step (defaults to 1) + int pos = 0; + + loop: while (true) { + Token next = iterator.expectPeek(TokenType.NUMBER, TokenType.RBRACKET, TokenType.COLON); + switch (next.type) { + case NUMBER: + iterator.expect(TokenType.NUMBER); + parts[pos] = next.value.asNumberValue().intValue(); + iterator.expectPeek(TokenType.COLON, TokenType.RBRACKET); + break; + case RBRACKET: + break loop; + default: // COLON + iterator.expect(TokenType.COLON); + if (++pos == 3) { + throw iterator.syntax("Too many colons in slice expression"); + } + break; + } + } + + iterator.expect(TokenType.RBRACKET); + + if (pos == 0) { + // No colons were found, so this is a simple index extraction. + return new IndexExpression(parts[0], line, column); + } + + // Sliced array from start (e.g., [2:]). A projection is created here + // because a projection has very similar semantics to what's actually + // happening here (i.e., turn the LHS into an array, take specific + // items from it, then pass the result to RHS). The only difference + // between foo[*] and foo[1:] is the size of the array. Anything that + // selects more than one element is a generally a projection. + JmespathExpression slice = new SliceExpression(parts[0], parts[1], parts[2], line, column); + JmespathExpression rhs = parseProjectionRhs(TokenType.STAR.lbp); + return new ProjectionExpression(slice, rhs, line, column); + } + + private JmespathExpression parseMultiList() { + int line = iterator.line(); + int column = iterator.column(); + List nodes = parseList(TokenType.RBRACKET); + return new MultiSelectListExpression(nodes, line, column); + } + + // Parse a comma separated list of expressions until a closing token. + // + // This function is used for functions and multi-list parsing. Note + // that this function allows empty lists. This is fine when parsing + // multi-list expressions because "[]" is tokenized as Token::Flatten. + // + // Examples: [foo, bar], foo(bar), foo(), foo(baz, bar). + private List parseList(TokenType closing) { + List nodes = new ArrayList<>(); + + while (iterator.peek().type != closing) { + nodes.add(expression(0)); + // Skip commas. + if (iterator.peek().type == TokenType.COMMA) { + iterator.expect(TokenType.COMMA); + if (iterator.peek().type == closing) { + throw iterator.syntax("Invalid token after ',': " + iterator.peek()); + } + } + } + + iterator.expect(closing); + return nodes; + } + + private JmespathExpression parseNudLbrace() { + int line = iterator.line(); + int column = iterator.column(); + Map entries = new LinkedHashMap<>(); + + while (iterator.hasNext()) { + // A multi-select-hash requires at least one key value pair. + Token key = iterator.expect(TokenType.IDENTIFIER); + iterator.expect(TokenType.COLON); + JmespathExpression value = expression(0); + entries.put(key.value.asStringValue(), value); + + if (iterator.expectPeek(TokenType.RBRACE, TokenType.COMMA).type == TokenType.COMMA) { + iterator.expect(TokenType.COMMA); + } else { + break; + } + } + + iterator.expect(TokenType.RBRACE); + return new MultiSelectHashExpression(entries, line, column); + } + + // Creates a projection for "[*]". + private JmespathExpression parseWildcardIndex(JmespathExpression left) { + int line = iterator.line(); + int column = iterator.column(); + iterator.expect(TokenType.STAR); + iterator.expect(TokenType.RBRACKET); + JmespathExpression right = parseProjectionRhs(TokenType.STAR.lbp); + return new ProjectionExpression(left, right, line, column); + } + + // Creates a projection for "*". + private JmespathExpression parseWildcardObject(JmespathExpression left) { + int line = iterator.line(); + int column = iterator.column() - 1; // backtrack + return new ObjectProjectionExpression(left, parseProjectionRhs(TokenType.STAR.lbp), line, column); + } + + // Creates a projection for "[]" that wraps the LHS to flattens the result. + private JmespathExpression parseFlatten(JmespathExpression left) { + int line = iterator.line(); + int column = iterator.column(); + JmespathExpression flatten = new FlattenExpression(left, left.getLine(), left.getColumn()); + JmespathExpression right = parseProjectionRhs(TokenType.STAR.lbp); + return new ProjectionExpression(flatten, right, line, column); + } + + // Parses the right hand side of a projection, using the given LBP to + // determine when to stop consuming tokens. + private JmespathExpression parseProjectionRhs(int lbp) { + Token next = iterator.expectNotEof(); + if (next.type == TokenType.DOT) { + // foo.*.bar + iterator.expect(TokenType.DOT); + return parseDotRhs(lbp); + } else if (next.type == TokenType.LBRACKET || next.type == TokenType.FILTER) { + // foo[*][1], foo[*][?baz] + return expression(lbp); + } else if (next.type.lbp < PROJECTION_STOP) { + // foo.* || bar + return new CurrentExpression(next.line, next.column); + } else { + throw iterator.syntax("Invalid projection"); + } + } + + private JmespathExpression parseComparator(ComparatorType comparatorType, JmespathExpression lhs) { + int line = iterator.line(); + int column = iterator.column(); + JmespathExpression rhs = expression(TokenType.EQUAL.lbp); + return new ComparisonExpression(comparatorType, lhs, rhs, line, column); + } + + // Parses the right hand side of a ".". + private JmespathExpression parseDotRhs(int lbp) { + Token token = iterator.expectPeek( + TokenType.LBRACKET, + TokenType.LBRACE, + TokenType.STAR, + TokenType.IDENTIFIER); + + if (token.type == TokenType.LBRACKET) { + // Skip '[', parse the list. + iterator.next(); + return parseMultiList(); + } else { + return expression(lbp); + } + } + + // Parses a filter token into a Projection that filters the right + // side of the projection using a comparison node. If the comparison + // returns a truthy value, then the value is yielded by the projection + // to the right hand side. + private JmespathExpression parseFilter(JmespathExpression left) { + // Parse the LHS of the condition node. + JmespathExpression condition = expression(0); + // Eat the closing bracket. + iterator.expect(TokenType.RBRACKET); + JmespathExpression conditionRhs = parseProjectionRhs(TokenType.FILTER.lbp); + return new FilterProjectionExpression( + left, + condition, + conditionRhs, + condition.getLine(), + condition.getColumn()); + } +} diff --git a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/RuntimeType.java b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/RuntimeType.java new file mode 100644 index 00000000000..1063a1ce7c2 --- /dev/null +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/RuntimeType.java @@ -0,0 +1,34 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.smithy.jmespath; + +import java.util.Locale; + +public enum RuntimeType { + STRING, + NUMBER, + BOOLEAN, + NULL, + ARRAY, + OBJECT, + EXPRESSION_REFERENCE, + ANY; + + @Override + public String toString() { + return super.toString().toLowerCase(Locale.ENGLISH); + } +} diff --git a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/Token.java b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/Token.java new file mode 100644 index 00000000000..5d313f9db31 --- /dev/null +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/Token.java @@ -0,0 +1,49 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.smithy.jmespath; + +import software.amazon.smithy.jmespath.ast.LiteralExpression; + +final class Token { + + /** The type of token. */ + final TokenType type; + + /** The nullable value contained in the token (e.g., a number or string). */ + final LiteralExpression value; + + /** The line where the token was parsed. */ + final int line; + + /** The column in the line where the token was parsed. */ + final int column; + + Token(TokenType type, LiteralExpression value, int line, int column) { + this.type = type; + this.value = value; + this.line = line; + this.column = column; + } + + @Override + public String toString() { + if (value != null) { + return '\'' + value.getValue().toString().replace("'", "\\'") + '\''; + } else { + return type.toString(); + } + } +} diff --git a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/TokenIterator.java b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/TokenIterator.java new file mode 100644 index 00000000000..d51447c6294 --- /dev/null +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/TokenIterator.java @@ -0,0 +1,127 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.smithy.jmespath; + +import java.util.Arrays; +import java.util.Iterator; +import java.util.List; +import java.util.NoSuchElementException; + +final class TokenIterator implements Iterator { + + private final List tokens; + private int position; + + TokenIterator(List tokens) { + this.tokens = tokens; + } + + @Override + public boolean hasNext() { + return position < tokens.size(); + } + + @Override + public Token next() { + if (!hasNext()) { + throw new NoSuchElementException("Attempted to parse past token EOF"); + } + + return tokens.get(position++); + } + + Token peek() { + return peek(0); + } + + Token peek(int offset) { + return position + offset < tokens.size() + ? tokens.get(position + offset) + : null; + } + + Token expectNotEof() { + Token peeked = peek(); + if (peeked == null) { + throw syntax("Expected more tokens but found EOF"); + } + return peeked; + } + + Token expectPeek(TokenType type) { + Token peeked = peek(); + if (peeked == null) { + throw syntax("Expected " + type + ", but found EOF"); + } else if (peeked.type != type) { + throw syntax("Expected " + type + ", but found " + peeked); + } else { + return peeked; + } + } + + Token expectPeek(TokenType... types) { + Token peeked = peek(); + if (peeked == null) { + throw syntax("Expected " + Arrays.toString(types) + ", but found EOF"); + } + + for (TokenType type : types) { + if (peeked.type == type) { + return peeked; + } + } + + throw syntax("Expected " + Arrays.toString(types) + ", but found " + peeked); + } + + Token expect(TokenType type) { + Token peeked = expectPeek(type); + next(); + return peeked; + } + + Token expect(TokenType... types) { + Token peeked = expectPeek(types); + next(); + return peeked; + } + + JmespathException syntax(String message) { + return new JmespathException("Syntax error at line " + line() + " column " + column() + ": " + message); + } + + int line() { + Token peeked = peek(); + if (peeked != null) { + return peeked.line; + } else if (position > 0) { + return tokens.get(position - 1).line; + } else { + return 1; + } + } + + int column() { + Token peeked = peek(); + if (peeked != null) { + return peeked.column; + } else if (position > 0) { + return tokens.get(position - 1).column; + } else { + return 1; + } + } +} diff --git a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/TokenType.java b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/TokenType.java new file mode 100644 index 00000000000..006b72e60a2 --- /dev/null +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/TokenType.java @@ -0,0 +1,67 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.smithy.jmespath; + +enum TokenType { + + EOF(null, -1), + IDENTIFIER("A-Z|a-z|_", 0), + LITERAL("`", 0), + RBRACKET("]", 0), + RPAREN(")", 0), + COMMA(",", 0), + RBRACE("]", 0), + NUMBER("-|0-9", 0), + CURRENT("@", 0), + EXPREF("&", 0), + COLON(":", 0), + PIPE("|", 1), + OR("||", 2), + AND("&&", 3), + EQUAL("==", 5), + GREATER_THAN(">", 5), + LESS_THAN("<", 5), + GREATER_THAN_EQUAL(">=", 5), + LESS_THAN_EQUAL("<=", 5), + NOT_EQUAL("!=", 5), + FLATTEN("[]", 9), + + // All tokens above stop a projection. + STAR("*", 20), + FILTER("[?", 21), + DOT(".", 40), + NOT("!", 45), + LBRACE("{", 50), + LBRACKET("[", 55), + LPAREN("(", 60); + + final int lbp; + final String lexeme; + + TokenType(String lexeme, int lbp) { + this.lexeme = lexeme; + this.lbp = lbp; + } + + @Override + public String toString() { + if (lexeme != null) { + return '\'' + lexeme.replace("'", "\\'") + '\''; + } else { + return super.toString(); + } + } +} diff --git a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/TypeChecker.java b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/TypeChecker.java new file mode 100644 index 00000000000..da1d7d8e3dd --- /dev/null +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/TypeChecker.java @@ -0,0 +1,482 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.smithy.jmespath; + +import static software.amazon.smithy.jmespath.FunctionDefinition.isType; +import static software.amazon.smithy.jmespath.FunctionDefinition.listOfType; +import static software.amazon.smithy.jmespath.FunctionDefinition.oneOf; +import static software.amazon.smithy.jmespath.ast.LiteralExpression.ANY; +import static software.amazon.smithy.jmespath.ast.LiteralExpression.ARRAY; +import static software.amazon.smithy.jmespath.ast.LiteralExpression.BOOLEAN; +import static software.amazon.smithy.jmespath.ast.LiteralExpression.EXPREF; +import static software.amazon.smithy.jmespath.ast.LiteralExpression.NULL; +import static software.amazon.smithy.jmespath.ast.LiteralExpression.NUMBER; +import static software.amazon.smithy.jmespath.ast.LiteralExpression.OBJECT; +import static software.amazon.smithy.jmespath.ast.LiteralExpression.STRING; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import software.amazon.smithy.jmespath.ast.AndExpression; +import software.amazon.smithy.jmespath.ast.ComparatorType; +import software.amazon.smithy.jmespath.ast.ComparisonExpression; +import software.amazon.smithy.jmespath.ast.CurrentExpression; +import software.amazon.smithy.jmespath.ast.ExpressionReferenceExpression; +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; + +final class TypeChecker implements ExpressionVisitor { + + private static final Map FUNCTIONS = new HashMap<>(); + + static { + FunctionDefinition.ArgValidator isAny = isType(RuntimeType.ANY); + FunctionDefinition.ArgValidator isString = isType(RuntimeType.STRING); + FunctionDefinition.ArgValidator isNumber = isType(RuntimeType.NUMBER); + FunctionDefinition.ArgValidator isArray = isType(RuntimeType.ARRAY); + + FUNCTIONS.put("abs", new FunctionDefinition(NUMBER, isNumber)); + FUNCTIONS.put("avg", new FunctionDefinition(NUMBER, listOfType(RuntimeType.NUMBER))); + FUNCTIONS.put("contains", new FunctionDefinition( + BOOLEAN, oneOf(RuntimeType.ARRAY, RuntimeType.STRING), isAny)); + FUNCTIONS.put("ceil", new FunctionDefinition(NUMBER, isNumber)); + FUNCTIONS.put("ends_with", new FunctionDefinition(NUMBER, isString, isString)); + FUNCTIONS.put("floor", new FunctionDefinition(NUMBER, isNumber)); + FUNCTIONS.put("join", new FunctionDefinition(STRING, isString, listOfType(RuntimeType.STRING))); + FUNCTIONS.put("keys", new FunctionDefinition(ARRAY, isType(RuntimeType.OBJECT))); + FUNCTIONS.put("length", new FunctionDefinition( + NUMBER, oneOf(RuntimeType.STRING, RuntimeType.ARRAY, RuntimeType.OBJECT))); + // TODO: Support expression reference return type validation? + FUNCTIONS.put("map", new FunctionDefinition(ARRAY, isType(RuntimeType.EXPRESSION_REFERENCE), isArray)); + // TODO: support array + FUNCTIONS.put("max", new FunctionDefinition(NUMBER, isArray)); + FUNCTIONS.put("max_by", new FunctionDefinition(NUMBER, isArray, isType(RuntimeType.EXPRESSION_REFERENCE))); + FUNCTIONS.put("merge", new FunctionDefinition(OBJECT, Collections.emptyList(), isType(RuntimeType.OBJECT))); + FUNCTIONS.put("min", new FunctionDefinition(NUMBER, isArray)); + FUNCTIONS.put("min_by", new FunctionDefinition(NUMBER, isArray, isType(RuntimeType.EXPRESSION_REFERENCE))); + FUNCTIONS.put("not_null", new FunctionDefinition(ANY, Collections.singletonList(isAny), isAny)); + FUNCTIONS.put("reverse", new FunctionDefinition(ARRAY, oneOf(RuntimeType.ARRAY, RuntimeType.STRING))); + FUNCTIONS.put("sort", new FunctionDefinition(ARRAY, isArray)); + FUNCTIONS.put("sort_by", new FunctionDefinition(ARRAY, isArray, isType(RuntimeType.EXPRESSION_REFERENCE))); + FUNCTIONS.put("starts_with", new FunctionDefinition(BOOLEAN, isString, isString)); + FUNCTIONS.put("sum", new FunctionDefinition(NUMBER, listOfType(RuntimeType.NUMBER))); + FUNCTIONS.put("to_array", new FunctionDefinition(ARRAY, isAny)); + FUNCTIONS.put("to_string", new FunctionDefinition(STRING, isAny)); + FUNCTIONS.put("to_number", new FunctionDefinition(NUMBER, isAny)); + FUNCTIONS.put("type", new FunctionDefinition(STRING, isAny)); + FUNCTIONS.put("values", new FunctionDefinition(ARRAY, isType(RuntimeType.OBJECT))); + } + + private final LiteralExpression current; + private final Set problems; + private LiteralExpression knownFunctionType = ANY; + + TypeChecker(LiteralExpression current, Set problems) { + this.current = current; + this.problems = problems; + } + + @Override + public LiteralExpression visitComparison(ComparisonExpression expression) { + LiteralExpression left = expression.getLeft().accept(this); + LiteralExpression right = expression.getRight().accept(this); + + // Different types always cause a comparison to not match. + if (left.getType() != right.getType()) { + return BOOLEAN; + } + + // I'm so sorry for the following code. + switch (left.getType()) { + case STRING: + switch (expression.getComparator()) { + case EQUAL: + return new LiteralExpression(left.asStringValue().equals(right.asStringValue())); + case NOT_EQUAL: + return new LiteralExpression(!left.asStringValue().equals(right.asStringValue())); + default: + badComparator(expression, left.getType(), expression.getComparator()); + return NULL; + } + case NUMBER: + double comparison = left.asNumberValue().doubleValue() - right.asNumberValue().doubleValue(); + switch (expression.getComparator()) { + case EQUAL: + return new LiteralExpression(comparison == 0); + case NOT_EQUAL: + return new LiteralExpression(comparison != 0); + case GREATER_THAN: + return new LiteralExpression(comparison > 0); + case GREATER_THAN_EQUAL: + return new LiteralExpression(comparison >= 0); + case LESS_THAN: + return new LiteralExpression(comparison < 0); + case LESS_THAN_EQUAL: + return new LiteralExpression(comparison <= 0); + default: + throw new IllegalArgumentException("Unreachable comparator " + expression.getComparator()); + } + case BOOLEAN: + switch (expression.getComparator()) { + case EQUAL: + return new LiteralExpression(left.asBooleanValue() == right.asBooleanValue()); + case NOT_EQUAL: + return new LiteralExpression(left.asBooleanValue() != right.asBooleanValue()); + default: + badComparator(expression, left.getType(), expression.getComparator()); + return NULL; + } + case NULL: + switch (expression.getComparator()) { + case EQUAL: + return new LiteralExpression(true); + case NOT_EQUAL: + return new LiteralExpression(false); + default: + badComparator(expression, left.getType(), expression.getComparator()); + return NULL; + } + case ARRAY: + switch (expression.getComparator()) { + case EQUAL: + return new LiteralExpression(left.asArrayValue().equals(right.asArrayValue())); + case NOT_EQUAL: + return new LiteralExpression(!left.asArrayValue().equals(right.asArrayValue())); + default: + badComparator(expression, left.getType(), expression.getComparator()); + return NULL; + } + case EXPRESSION_REFERENCE: + badComparator(expression, left.getType(), expression.getComparator()); + return NULL; + case OBJECT: + switch (expression.getComparator()) { + case EQUAL: + return new LiteralExpression(left.asObjectValue().equals(right.asObjectValue())); + case NOT_EQUAL: + return new LiteralExpression(!left.asObjectValue().equals(right.asObjectValue())); + default: + badComparator(expression, left.getType(), expression.getComparator()); + return NULL; + } + default: // ANY + // Just assume any kind of ANY comparison is satisfied. + return new LiteralExpression(true); + } + } + + @Override + public LiteralExpression visitCurrentNode(CurrentExpression expression) { + return current; + } + + @Override + public LiteralExpression visitExpressionReference(ExpressionReferenceExpression expression) { + // Expression references are late bound, so the type is only known + // when the reference is used in a function. + expression.getExpression().accept(new TypeChecker(knownFunctionType, problems)); + return EXPREF; + } + + @Override + public LiteralExpression visitFlatten(FlattenExpression expression) { + LiteralExpression result = expression.getExpression().accept(this); + + if (!result.isArrayValue()) { + if (result.getType() != RuntimeType.ANY) { + danger(expression, "Array flatten performed on " + result.getType()); + } + return ARRAY; + } + + // Perform the actual flattening. + List flattened = new ArrayList<>(); + for (Object value : result.asArrayValue()) { + LiteralExpression element = LiteralExpression.from(value); + if (element.isArrayValue()) { + flattened.addAll(element.asArrayValue()); + } else if (!element.isNullValue()) { + flattened.add(element); + } + } + + return new LiteralExpression(flattened); + } + + @Override + public LiteralExpression visitField(FieldExpression expression) { + if (current.isObjectValue()) { + if (current.hasObjectField(expression.getName())) { + return current.getObjectField(expression.getName()); + } else { + danger(expression, String.format( + "Object field '%s' does not exist in object with properties %s", + expression.getName(), current.asObjectValue().keySet())); + return NULL; + } + } + + if (current.getType() != RuntimeType.ANY) { + danger(expression, String.format( + "Object field '%s' extraction performed on %s", expression.getName(), current.getType())); + } + + return ANY; + } + + @Override + public LiteralExpression visitIndex(IndexExpression expression) { + if (current.isArrayValue()) { + return current.getArrayIndex(expression.getIndex()); + } + + if (current.getType() != RuntimeType.ANY) { + danger(expression, String.format( + "Array index '%s' extraction performed on %s", expression.getIndex(), current.getType())); + } + + return ANY; + } + + @Override + public LiteralExpression visitLiteral(LiteralExpression expression) { + return expression; + } + + @Override + public LiteralExpression visitMultiSelectList(MultiSelectListExpression expression) { + List values = new ArrayList<>(); + for (JmespathExpression e : expression.getExpressions()) { + values.add(e.accept(this).getValue()); + } + return new LiteralExpression(values); + } + + @Override + public LiteralExpression visitMultiSelectHash(MultiSelectHashExpression expression) { + Map result = new LinkedHashMap<>(); + for (Map.Entry entry : expression.getExpressions().entrySet()) { + result.put(entry.getKey(), entry.getValue().accept(this).getValue()); + } + return new LiteralExpression(result); + } + + @Override + public LiteralExpression visitAnd(AndExpression expression) { + LiteralExpression leftResult = expression.getLeft().accept(this); + + // Visit right side regardless of the evaluation of the left side to validate the result. + TypeChecker checker = new TypeChecker(leftResult, problems); + LiteralExpression rightResult = expression.getRight().accept(checker); + + // Return a proper result based on the evaluation. + if (leftResult.isTruthy() && rightResult.isTruthy()) { + return rightResult; + } else { + return NULL; + } + } + + @Override + public LiteralExpression visitOr(OrExpression expression) { + LiteralExpression leftResult = expression.getLeft().accept(this); + // Visit right side regardless of the evaluation of the left side to validate the result. + LiteralExpression rightResult = expression.getRight().accept(this); + return leftResult.isTruthy() ? leftResult : rightResult; + } + + @Override + public LiteralExpression visitNot(NotExpression expression) { + LiteralExpression result = expression.getExpression().accept(this); + return new LiteralExpression(!result.isTruthy()); + } + + @Override + public LiteralExpression visitProjection(ProjectionExpression expression) { + LiteralExpression leftResult = expression.getLeft().accept(this); + + // If LHS is not an array, then just do basic checks on RHS using ANY + ARRAY. + if (!leftResult.isArrayValue() || leftResult.asArrayValue().isEmpty()) { + if (leftResult.getType() != RuntimeType.ANY && !leftResult.isArrayValue()) { + danger(expression, "Array projection performed on " + leftResult.getType()); + } + // Run RHS once using an ANY to test it too. + expression.getRight().accept(new TypeChecker(ANY, problems)); + return ARRAY; + } else { + // LHS is an array, so do the projection. + List result = new ArrayList<>(); + for (Object value : leftResult.asArrayValue()) { + TypeChecker checker = new TypeChecker(LiteralExpression.from(value), problems); + result.add(expression.getRight().accept(checker).getValue()); + } + return new LiteralExpression(result); + } + } + + @Override + public LiteralExpression visitObjectProjection(ObjectProjectionExpression expression) { + LiteralExpression leftResult = expression.getLeft().accept(this); + + // If LHS is not an object, then just do basic checks on RHS using ANY + OBJECT. + if (!leftResult.isObjectValue()) { + if (leftResult.getType() != RuntimeType.ANY) { + danger(expression, "Object projection performed on " + leftResult.getType()); + } + TypeChecker checker = new TypeChecker(ANY, problems); + expression.getRight().accept(checker); + return OBJECT; + } + + // LHS is an object, so do the projection. + List result = new ArrayList<>(); + for (Object value : leftResult.asObjectValue().values()) { + TypeChecker checker = new TypeChecker(LiteralExpression.from(value), problems); + result.add(expression.getRight().accept(checker).getValue()); + } + + return new LiteralExpression(result); + } + + @Override + public LiteralExpression visitFilterProjection(FilterProjectionExpression expression) { + LiteralExpression leftResult = expression.getLeft().accept(this); + + // If LHS is not an array or is empty, then just do basic checks on RHS using ANY + ARRAY. + if (!leftResult.isArrayValue() || leftResult.asArrayValue().isEmpty()) { + if (!leftResult.isArrayValue() && leftResult.getType() != RuntimeType.ANY) { + danger(expression, "Filter projection performed on " + leftResult.getType()); + } + // Check the comparator and RHS. + TypeChecker rightVisitor = new TypeChecker(ANY, problems); + expression.getComparison().accept(rightVisitor); + expression.getRight().accept(rightVisitor); + return ARRAY; + } + + // It's a non-empty array, perform the actual filter. + List result = new ArrayList<>(); + for (Object value : leftResult.asArrayValue()) { + LiteralExpression literalValue = LiteralExpression.from(value); + TypeChecker rightVisitor = new TypeChecker(literalValue, problems); + LiteralExpression comparisonValue = expression.getComparison().accept(rightVisitor); + if (comparisonValue.isTruthy()) { + LiteralExpression rightValue = expression.getRight().accept(rightVisitor); + if (!rightValue.isNullValue()) { + result.add(rightValue.getValue()); + } + } + } + + return new LiteralExpression(result); + } + + @Override + public LiteralExpression visitSlice(SliceExpression expression) { + // We don't need to actually perform a slice here since this is just basic static analysis. + if (current.isArrayValue()) { + return current; + } + + if (current.getType() != RuntimeType.ANY) { + danger(expression, "Slice performed on " + current.getType()); + } + + return ARRAY; + } + + @Override + public LiteralExpression visitSubexpression(Subexpression expression) { + LiteralExpression leftResult = expression.getLeft().accept(this); + TypeChecker rightVisitor = new TypeChecker(leftResult, problems); + return expression.getRight().accept(rightVisitor); + } + + @Override + public LiteralExpression visitFunction(FunctionExpression expression) { + List arguments = new ArrayList<>(); + + // Give expression references the right context. + TypeChecker checker = new TypeChecker(current, problems); + checker.knownFunctionType = current; + + for (JmespathExpression arg : expression.getArguments()) { + arguments.add(arg.accept(checker)); + } + + FunctionDefinition def = FUNCTIONS.get(expression.getName()); + + // Function must be known. + if (def == null) { + err(expression, "Unknown function: " + expression.getName()); + return ANY; + } + + // Positional argument arity must match. + if (arguments.size() < def.arguments.size() + || (def.variadic == null && arguments.size() > def.arguments.size())) { + err(expression, expression.getName() + " function expected " + def.arguments.size() + + " arguments, but was given " + arguments.size()); + } else { + for (int i = 0; i < arguments.size(); i++) { + String error = null; + if (def.arguments.size() > i) { + error = def.arguments.get(i).validate(arguments.get(i)); + } else if (def.variadic != null) { + error = def.variadic.validate(arguments.get(i)); + } + if (error != null) { + err(expression.getArguments().get(i), + expression.getName() + " function argument " + i + " error: " + error); + } + } + } + + return def.returnValue; + } + + private void err(JmespathExpression e, String message) { + problems.add(new ExpressionProblem(ExpressionProblem.Severity.ERROR, e.getLine(), e.getColumn(), message)); + } + + private void danger(JmespathExpression e, String message) { + problems.add(new ExpressionProblem(ExpressionProblem.Severity.DANGER, e.getLine(), e.getColumn(), message)); + } + + private void warn(JmespathExpression e, String message) { + problems.add(new ExpressionProblem(ExpressionProblem.Severity.WARNING, e.getLine(), e.getColumn(), message)); + } + + private void badComparator(JmespathExpression expression, RuntimeType type, ComparatorType comparatorType) { + warn(expression, "Invalid comparator '" + comparatorType + "' for " + type); + } +} diff --git a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/AndExpression.java b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/AndExpression.java new file mode 100644 index 00000000000..08712d47cdf --- /dev/null +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/AndExpression.java @@ -0,0 +1,39 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.smithy.jmespath.ast; + +import software.amazon.smithy.jmespath.ExpressionVisitor; +import software.amazon.smithy.jmespath.JmespathExpression; + +/** + * And expression where both sides must return truthy values. The second + * truthy value becomes the result of the expression. + */ +public final class AndExpression extends BinaryExpression { + + public AndExpression(JmespathExpression left, JmespathExpression right) { + this(left, right, 1, 1); + } + + public AndExpression(JmespathExpression left, JmespathExpression right, int line, int column) { + super(left, right, line, column); + } + + @Override + public T accept(ExpressionVisitor visitor) { + return visitor.visitAnd(this); + } +} diff --git a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/BinaryExpression.java b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/BinaryExpression.java new file mode 100644 index 00000000000..ea685c8f8c7 --- /dev/null +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/BinaryExpression.java @@ -0,0 +1,73 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.smithy.jmespath.ast; + +import java.util.Objects; +import software.amazon.smithy.jmespath.JmespathExpression; + +/** + * Abstract class representing expressions that have a left and right side. + */ +public abstract class BinaryExpression extends JmespathExpression { + + private final JmespathExpression left; + private final JmespathExpression right; + + public BinaryExpression(JmespathExpression left, JmespathExpression right, int line, int column) { + super(line, column); + this.left = left; + this.right = right; + } + + /** + * Gets the left side of the expression. + * + * @return Returns the expression on the left. + */ + public final JmespathExpression getLeft() { + return left; + } + + /** + * Gets the right side of the expression. + * + * @return Returns the expression on the right. + */ + public final JmespathExpression getRight() { + return right; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } else if (!(o instanceof BinaryExpression) || o.getClass() != o.getClass()) { + return false; + } + BinaryExpression that = (BinaryExpression) o; + return getLeft().equals(that.getLeft()) && getRight().equals(that.getRight()); + } + + @Override + public int hashCode() { + return Objects.hash(getLeft(), getRight()); + } + + @Override + public String toString() { + return getClass().getSimpleName() + "{left=" + left + ", right=" + right + '}'; + } +} diff --git a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/ComparatorType.java b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/ComparatorType.java new file mode 100644 index 00000000000..d527b0fc728 --- /dev/null +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/ComparatorType.java @@ -0,0 +1,40 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.smithy.jmespath.ast; + +/** + * A comparator in a comparison expression. + */ +public enum ComparatorType { + + EQUAL("=="), + NOT_EQUAL("!="), + LESS_THAN("<"), + LESS_THAN_EQUAL("<="), + GREATER_THAN(">"), + GREATER_THAN_EQUAL(">="); + + private final String value; + + ComparatorType(String value) { + this.value = value; + } + + @Override + public String toString() { + return value; + } +} diff --git a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/ComparisonExpression.java b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/ComparisonExpression.java new file mode 100644 index 00000000000..c6ab55d38b6 --- /dev/null +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/ComparisonExpression.java @@ -0,0 +1,83 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.smithy.jmespath.ast; + +import java.util.Objects; +import software.amazon.smithy.jmespath.ExpressionVisitor; +import software.amazon.smithy.jmespath.JmespathExpression; + +/** + * Compares the left and right expression using a comparator, + * resulting in a boolean value. + */ +public final class ComparisonExpression extends BinaryExpression { + + private final ComparatorType comparator; + + public ComparisonExpression(ComparatorType comparator, JmespathExpression left, JmespathExpression right) { + this(comparator, left, right, 1, 1); + } + + public ComparisonExpression( + ComparatorType comparator, + JmespathExpression left, + JmespathExpression right, + int line, + int column + ) { + super(left, right, line, column); + this.comparator = comparator; + } + + @Override + public T accept(ExpressionVisitor visitor) { + return visitor.visitComparison(this); + } + + /** + * Gets the comparator to apply to the left and right expressions. + * + * @return Returns the comparator. + */ + public ComparatorType getComparator() { + return comparator; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } else if (!(o instanceof ComparisonExpression)) { + return false; + } + ComparisonExpression that = (ComparisonExpression) o; + return getLeft().equals(that.getLeft()) + && getRight().equals(that.getRight()) + && getComparator().equals(that.getComparator()); + } + + @Override + public int hashCode() { + return Objects.hash(getLeft(), getRight(), getComparator()); + } + + @Override + public String toString() { + return "ComparatorExpression{comparator='" + getComparator() + '\'' + + ", left=" + getLeft() + + ", right=" + getRight() + '}'; + } +} diff --git a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/CurrentExpression.java b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/CurrentExpression.java new file mode 100644 index 00000000000..7d8b5d33274 --- /dev/null +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/CurrentExpression.java @@ -0,0 +1,53 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.smithy.jmespath.ast; + +import software.amazon.smithy.jmespath.ExpressionVisitor; +import software.amazon.smithy.jmespath.JmespathExpression; + +/** + * Gets the current node. + */ +public final class CurrentExpression extends JmespathExpression { + + public CurrentExpression() { + this(1, 1); + } + + public CurrentExpression(int line, int column) { + super(line, column); + } + + @Override + public T accept(ExpressionVisitor visitor) { + return visitor.visitCurrentNode(this); + } + + @Override + public int hashCode() { + return 1; + } + + @Override + public boolean equals(Object other) { + return other instanceof CurrentExpression; + } + + @Override + public String toString() { + return "CurrentExpression{}"; + } +} diff --git a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/ExpressionReferenceExpression.java b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/ExpressionReferenceExpression.java new file mode 100644 index 00000000000..c1a78c04707 --- /dev/null +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/ExpressionReferenceExpression.java @@ -0,0 +1,73 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.smithy.jmespath.ast; + +import java.util.Objects; +import software.amazon.smithy.jmespath.ExpressionVisitor; +import software.amazon.smithy.jmespath.JmespathExpression; + +/** + * Contains a reference to an expression that can be run zero or more + * times by a function. + */ +public final class ExpressionReferenceExpression extends JmespathExpression { + + private final JmespathExpression expression; + + public ExpressionReferenceExpression(JmespathExpression expression) { + this(expression, 1, 1); + } + + public ExpressionReferenceExpression(JmespathExpression expression, int line, int column) { + super(line, column); + this.expression = expression; + } + + @Override + public T accept(ExpressionVisitor visitor) { + return visitor.visitExpressionReference(this); + } + + /** + * Gets the contained expression. + * + * @return Returns the contained expression. + */ + public JmespathExpression getExpression() { + return expression; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } else if (!(o instanceof ExpressionReferenceExpression)) { + return false; + } + ExpressionReferenceExpression that = (ExpressionReferenceExpression) o; + return expression.equals(that.expression); + } + + @Override + public int hashCode() { + return Objects.hash(expression); + } + + @Override + public String toString() { + return "ExpressionReferenceExpression{expression=" + expression + '}'; + } +} diff --git a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/FieldExpression.java b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/FieldExpression.java new file mode 100644 index 00000000000..cda999063a1 --- /dev/null +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/FieldExpression.java @@ -0,0 +1,72 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.smithy.jmespath.ast; + +import java.util.Objects; +import software.amazon.smithy.jmespath.ExpressionVisitor; +import software.amazon.smithy.jmespath.JmespathExpression; + +/** + * Gets a field by name from an object. + */ +public final class FieldExpression extends JmespathExpression { + + private final String name; + + public FieldExpression(String name) { + this(name, 1, 1); + } + + public FieldExpression(String name, int line, int column) { + super(line, column); + this.name = name; + } + + @Override + public T accept(ExpressionVisitor visitor) { + return visitor.visitField(this); + } + + /** + * Get the name of the field to retrieve. + * + * @return Returns the name of the field. + */ + public String getName() { + return name; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } else if (!(o instanceof FieldExpression)) { + return false; + } else { + return getName().equals(((FieldExpression) o).getName()); + } + } + + @Override + public int hashCode() { + return Objects.hash(getName()); + } + + @Override + public String toString() { + return "FieldExpression{name='" + name + '\'' + '}'; + } +} diff --git a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/FilterProjectionExpression.java b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/FilterProjectionExpression.java new file mode 100644 index 00000000000..ab83aaeb9d2 --- /dev/null +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/FilterProjectionExpression.java @@ -0,0 +1,91 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.smithy.jmespath.ast; + +import java.util.Objects; +import software.amazon.smithy.jmespath.ExpressionVisitor; +import software.amazon.smithy.jmespath.JmespathExpression; + +public final class FilterProjectionExpression extends JmespathExpression { + + private final JmespathExpression comparison; + private final JmespathExpression left; + private final JmespathExpression right; + + public FilterProjectionExpression( + JmespathExpression left, + JmespathExpression comparison, + JmespathExpression right + ) { + this(left, comparison, right, 1, 1); + } + + public FilterProjectionExpression( + JmespathExpression left, + JmespathExpression comparison, + JmespathExpression right, + int line, + int column + ) { + super(line, column); + this.left = left; + this.right = right; + this.comparison = comparison; + } + + public JmespathExpression getLeft() { + return left; + } + + public JmespathExpression getRight() { + return right; + } + + public JmespathExpression getComparison() { + return comparison; + } + + @Override + public T accept(ExpressionVisitor visitor) { + return visitor.visitFilterProjection(this); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } else if (!(o instanceof FilterProjectionExpression)) { + return false; + } + FilterProjectionExpression that = (FilterProjectionExpression) o; + return getComparison().equals(that.getComparison()) + && getLeft().equals(that.getLeft()) + && getRight().equals(that.getRight()); + } + + @Override + public int hashCode() { + return Objects.hash(getComparison(), getLeft(), getRight()); + } + + @Override + public String toString() { + return "FilterProjectionExpression{" + + "comparison=" + comparison + + ", left=" + left + + ", right=" + right + '}'; + } +} diff --git a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/FlattenExpression.java b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/FlattenExpression.java new file mode 100644 index 00000000000..6321e69caea --- /dev/null +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/FlattenExpression.java @@ -0,0 +1,72 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.smithy.jmespath.ast; + +import java.util.Objects; +import software.amazon.smithy.jmespath.ExpressionVisitor; +import software.amazon.smithy.jmespath.JmespathExpression; + +/** + * Flattens the wrapped expression into an array. + */ +public final class FlattenExpression extends JmespathExpression { + + private final JmespathExpression expression; + + public FlattenExpression(JmespathExpression expression) { + this(expression, 1, 1); + } + + public FlattenExpression(JmespathExpression expression, int line, int column) { + super(line, column); + this.expression = expression; + } + + @Override + public T accept(ExpressionVisitor visitor) { + return visitor.visitFlatten(this); + } + + /** + * Returns the expression being flattened. + * + * @return Returns the expression. + */ + public JmespathExpression getExpression() { + return expression; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } else if (!(o instanceof FlattenExpression)) { + return false; + } + FlattenExpression that = (FlattenExpression) o; + return expression.equals(that.expression); + } + + @Override + public int hashCode() { + return Objects.hash(expression); + } + + @Override + public String toString() { + return "FlattenExpression{expression=" + expression + '}'; + } +} diff --git a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/FunctionExpression.java b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/FunctionExpression.java new file mode 100644 index 00000000000..1c893d5d052 --- /dev/null +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/FunctionExpression.java @@ -0,0 +1,84 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.smithy.jmespath.ast; + +import java.util.List; +import java.util.Objects; +import software.amazon.smithy.jmespath.ExpressionVisitor; +import software.amazon.smithy.jmespath.JmespathExpression; + +/** + * Executes a function by name using a list of argument expressions. + */ +public final class FunctionExpression extends JmespathExpression { + + public String name; + public List arguments; + + public FunctionExpression(String name, List arguments) { + this(name, arguments, 1, 1); + } + + public FunctionExpression(String name, List arguments, int line, int column) { + super(line, column); + this.name = name; + this.arguments = arguments; + } + + @Override + public T accept(ExpressionVisitor visitor) { + return visitor.visitFunction(this); + } + + /** + * Gets the function name. + * + * @return Returns the name. + */ + public String getName() { + return name; + } + + /** + * Gets the function arguments. + * + * @return Returns the argument expressions. + */ + public List getArguments() { + return arguments; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } else if (o == null || getClass() != o.getClass()) { + return false; + } + FunctionExpression that = (FunctionExpression) o; + return getName().equals(that.getName()) && getArguments().equals(that.getArguments()); + } + + @Override + public int hashCode() { + return Objects.hash(getName(), getArguments()); + } + + @Override + public String toString() { + return "FunctionExpression{name='" + name + '\'' + ", arguments=" + arguments + '}'; + } +} diff --git a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/IndexExpression.java b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/IndexExpression.java new file mode 100644 index 00000000000..552a30129f8 --- /dev/null +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/IndexExpression.java @@ -0,0 +1,73 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.smithy.jmespath.ast; + +import java.util.Objects; +import software.amazon.smithy.jmespath.ExpressionVisitor; +import software.amazon.smithy.jmespath.JmespathExpression; + +/** + * Gets a specific element by zero-based index. Use -1 to get the + * last element in an array. + */ +public final class IndexExpression extends JmespathExpression { + + private final int index; + + public IndexExpression(int index) { + this(index, 1, 1); + } + + public IndexExpression(int index, int line, int column) { + super(line, column); + this.index = index; + } + + @Override + public T accept(ExpressionVisitor visitor) { + return visitor.visitIndex(this); + } + + /** + * Gets the index to retrieve. + * + * @return Returns the index. + */ + public int getIndex() { + return index; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } else if (!(o instanceof IndexExpression)) { + return false; + } + IndexExpression other = (IndexExpression) o; + return getIndex() == other.getIndex(); + } + + @Override + public int hashCode() { + return Objects.hash(getIndex()); + } + + @Override + public String toString() { + return "IndexExpression{index=" + index + '}'; + } +} diff --git a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/LiteralExpression.java b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/LiteralExpression.java new file mode 100644 index 00000000000..3fc484efff0 --- /dev/null +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/LiteralExpression.java @@ -0,0 +1,345 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.smithy.jmespath.ast; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; +import software.amazon.smithy.jmespath.ExpressionVisitor; +import software.amazon.smithy.jmespath.JmespathException; +import software.amazon.smithy.jmespath.JmespathExpression; +import software.amazon.smithy.jmespath.RuntimeType; + +/** + * Represents a literal value. + */ +public final class LiteralExpression extends JmespathExpression { + + /** Sentinel value to represent ANY. */ + public static final LiteralExpression ANY = new LiteralExpression(new Object()); + + /** Sentinel value to represent any ARRAY. */ + public static final LiteralExpression ARRAY = new LiteralExpression(new ArrayList<>()); + + /** Sentinel value to represent any OBJECT. */ + public static final LiteralExpression OBJECT = new LiteralExpression(new HashMap<>()); + + /** Sentinel value to represent any BOOLEAN. */ + public static final LiteralExpression BOOLEAN = new LiteralExpression(false); + + /** Sentinel value to represent any STRING. */ + public static final LiteralExpression STRING = new LiteralExpression(""); + + /** Sentinel value to represent any NULL. */ + public static final LiteralExpression NUMBER = new LiteralExpression(0); + + /** Sentinel value to represent an expression reference. */ + public static final LiteralExpression EXPREF = new LiteralExpression((Function) o -> null); + + /** Sentinel value to represent null. */ + public static final LiteralExpression NULL = new LiteralExpression(null); + + private final Object value; + + public LiteralExpression(Object value) { + this(value, 1, 1); + } + + public LiteralExpression(Object value, int line, int column) { + super(line, column); + + // Unwrapped any wrapping that would mess up type checking. + if (value instanceof LiteralExpression) { + this.value = ((LiteralExpression) value).getValue(); + } else { + this.value = value; + } + } + + /** + * Creates a LiteralExpression from {@code value}, unwrapping it if necessary. + * + * @param value Value to create the expression from. + * @return Returns the LiteralExpression of the given {@code value}. + */ + public static LiteralExpression from(Object value) { + if (value instanceof LiteralExpression) { + return (LiteralExpression) value; + } else { + return new LiteralExpression(value); + } + } + + @Override + public T accept(ExpressionVisitor visitor) { + return visitor.visitLiteral(this); + } + + /** + * Gets the nullable value contained in the literal value. + * + * @return Returns the contained value. + */ + public Object getValue() { + return value; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } else if (!(o instanceof LiteralExpression)) { + return false; + } else { + return Objects.equals(value, ((LiteralExpression) o).value); + } + } + + @Override + public int hashCode() { + return Objects.hash(value); + } + + @Override + public String toString() { + return "LiteralExpression{value=" + value + '}'; + } + + /** + * Gets the type of the value. + * + * @return Returns the literal expression's runtime type. + */ + public RuntimeType getType() { + if (isArrayValue()) { + return RuntimeType.ARRAY; + } else if (isObjectValue()) { + return RuntimeType.OBJECT; + } else if (isStringValue()) { + return RuntimeType.STRING; + } else if (isBooleanValue()) { + return RuntimeType.BOOLEAN; + } else if (isNumberValue()) { + return RuntimeType.NUMBER; + } else if (isNullValue()) { + return RuntimeType.NULL; + } else if (this == EXPREF) { + return RuntimeType.EXPRESSION_REFERENCE; + } else { + return RuntimeType.ANY; + } + } + + /** + * Expects the value to be an object and gets a field by + * name. If the field does not exist, then a + * {@link LiteralExpression} with a null value is returned. + * + * @param name Field to get from the expected object. + * @return Returns the object field value. + */ + public LiteralExpression getObjectField(String name) { + Map values = asObjectValue(); + return values.containsKey(name) + ? new LiteralExpression(values.get(name)) + : new LiteralExpression(null); + } + + /** + * Expects the value to be an object and checks if it contains + * a field by name. + * + * @param name Field to get from the expected object. + * @return Returns true if the object contains the given key. + */ + public boolean hasObjectField(String name) { + return asObjectValue().containsKey(name); + } + + /** + * Expects the value to be an array and gets the value at the given + * index. If the index is negative, it is computed to the array + * length minus the index. If the computed index does not exist, + * a {@link LiteralExpression} with a null value is returned. + * + * @param index Index to get from the array. + * @return Returns the array value. + */ + public LiteralExpression getArrayIndex(int index) { + List values = asArrayValue(); + + if (index < 0) { + index = values.size() + index; + } + + return index >= 0 && values.size() > index + ? new LiteralExpression(values.get(index)) + : new LiteralExpression(null); + } + + /** + * Checks if the value is a string. + * + * @return Returns true if the value is a string. + */ + public boolean isStringValue() { + return value instanceof String; + } + + /** + * Checks if the value is a number. + * + * @return Returns true if the value is a number. + */ + public boolean isNumberValue() { + return value instanceof Number; + } + + /** + * Checks if the value is a boolean. + * + * @return Returns true if the value is a boolean. + */ + public boolean isBooleanValue() { + return value instanceof Boolean; + } + + /** + * Checks if the value is an array. + * + * @return Returns true if the value is an array. + */ + public boolean isArrayValue() { + return value instanceof List; + } + + /** + * Checks if the value is an object. + * + * @return Returns true if the value is an object. + */ + public boolean isObjectValue() { + return value instanceof Map; + } + + /** + * Checks if the value is null. + * + * @return Returns true if the value is null. + */ + public boolean isNullValue() { + return value == null; + } + + /** + * Gets the value as a string. + * + * @return Returns the string value. + * @throws JmespathException if the value is not a string. + */ + public String asStringValue() { + if (value instanceof String) { + return (String) value; + } + + throw new JmespathException("Expected a string literal, but found " + value.getClass()); + } + + /** + * Gets the value as a number. + * + * @return Returns the number value. + * @throws JmespathException if the value is not a number. + */ + public Number asNumberValue() { + if (value instanceof Number) { + return (Number) value; + } + + throw new JmespathException("Expected a number literal, but found " + value.getClass()); + } + + /** + * Gets the value as a boolean. + * + * @return Returns the boolean value. + * @throws JmespathException if the value is not a boolean. + */ + public boolean asBooleanValue() { + if (value instanceof Boolean) { + return (Boolean) value; + } + + throw new JmespathException("Expected a boolean literal, but found " + value.getClass()); + } + + /** + * Gets the value as an array. + * + * @return Returns the array value. + * @throws JmespathException if the value is not an array. + */ + @SuppressWarnings("unchecked") + public List asArrayValue() { + try { + return (List) value; + } catch (ClassCastException e) { + throw new JmespathException("Expected an array literal, but found " + value.getClass()); + } + } + + /** + * Gets the value as an object. + * + * @return Returns the object value. + * @throws JmespathException if the value is not an object. + */ + @SuppressWarnings("unchecked") + public Map asObjectValue() { + try { + return (Map) value; + } catch (ClassCastException e) { + throw new JmespathException("Expected a map literal, but found " + value.getClass()); + } + } + + /** + * Returns true if the value is truthy according to JMESPath. + * + * @return Returns true or false if truthy. + */ + public boolean isTruthy() { + switch (getType()) { + case ANY: // just assume it's true. + case NUMBER: // number is always true + case EXPRESSION_REFERENCE: // references are always true + return true; + case STRING: + return !asStringValue().isEmpty(); + case ARRAY: + return !asArrayValue().isEmpty(); + case OBJECT: + return !asObjectValue().isEmpty(); + case BOOLEAN: + return asBooleanValue(); + default: + return false; + } + } +} diff --git a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/MultiSelectHashExpression.java b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/MultiSelectHashExpression.java new file mode 100644 index 00000000000..7ebc5eba081 --- /dev/null +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/MultiSelectHashExpression.java @@ -0,0 +1,73 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.smithy.jmespath.ast; + +import java.util.Map; +import java.util.Objects; +import software.amazon.smithy.jmespath.ExpressionVisitor; +import software.amazon.smithy.jmespath.JmespathExpression; + +/** + * Creates an object using key-value pairs. + */ +public final class MultiSelectHashExpression extends JmespathExpression { + + private final Map expressions; + + public MultiSelectHashExpression(Map entries) { + this(entries, 1, 1); + } + + public MultiSelectHashExpression(Map expressions, int line, int column) { + super(line, column); + this.expressions = expressions; + } + + @Override + public T accept(ExpressionVisitor visitor) { + return visitor.visitMultiSelectHash(this); + } + + /** + * Gets the map of key-value pairs to add to the created object. + * + * @return Returns the map of key names to expressions. + */ + public Map getExpressions() { + return expressions; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } else if (!(o instanceof MultiSelectHashExpression)) { + return false; + } + MultiSelectHashExpression that = (MultiSelectHashExpression) o; + return getExpressions().equals(that.getExpressions()); + } + + @Override + public int hashCode() { + return Objects.hash(getExpressions()); + } + + @Override + public String toString() { + return "MultiSelectHashExpression{expressions=" + expressions + '}'; + } +} diff --git a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/MultiSelectListExpression.java b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/MultiSelectListExpression.java new file mode 100644 index 00000000000..c29cded010c --- /dev/null +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/MultiSelectListExpression.java @@ -0,0 +1,73 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.smithy.jmespath.ast; + +import java.util.List; +import java.util.Objects; +import software.amazon.smithy.jmespath.ExpressionVisitor; +import software.amazon.smithy.jmespath.JmespathExpression; + +/** + * Selects one or more values into a created array. + */ +public final class MultiSelectListExpression extends JmespathExpression { + + private final List expressions; + + public MultiSelectListExpression(List expressions) { + this(expressions, 1, 1); + } + + public MultiSelectListExpression(List expressions, int line, int column) { + super(line, column); + this.expressions = expressions; + } + + @Override + public T accept(ExpressionVisitor visitor) { + return visitor.visitMultiSelectList(this); + } + + /** + * Gets the ordered list of expressions to add to the list. + * + * @return Returns the expressions. + */ + public List getExpressions() { + return expressions; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } else if (!(o instanceof MultiSelectListExpression)) { + return false; + } + MultiSelectListExpression that = (MultiSelectListExpression) o; + return expressions.equals(that.expressions); + } + + @Override + public int hashCode() { + return Objects.hash(expressions); + } + + @Override + public String toString() { + return "MultiSelectListExpression{expressions=" + expressions + '}'; + } +} diff --git a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/NotExpression.java b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/NotExpression.java new file mode 100644 index 00000000000..ea0bc4436d3 --- /dev/null +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/NotExpression.java @@ -0,0 +1,72 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.smithy.jmespath.ast; + +import java.util.Objects; +import software.amazon.smithy.jmespath.ExpressionVisitor; +import software.amazon.smithy.jmespath.JmespathExpression; + +/** + * Negates an expression based on if the wrapped expression is truthy. + */ +public final class NotExpression extends JmespathExpression { + + private final JmespathExpression expression; + + public NotExpression(JmespathExpression wrapped) { + this(wrapped, 1, 1); + } + + public NotExpression(JmespathExpression expression, int line, int column) { + super(line, column); + this.expression = expression; + } + + @Override + public T accept(ExpressionVisitor visitor) { + return visitor.visitNot(this); + } + + /** + * Gets the contained expression to negate. + * + * @return Returns the contained expression. + */ + public JmespathExpression getExpression() { + return expression; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } else if (!(o instanceof NotExpression)) { + return false; + } + NotExpression notNode = (NotExpression) o; + return expression.equals(notNode.expression); + } + + @Override + public int hashCode() { + return Objects.hash(expression); + } + + @Override + public String toString() { + return "NotExpression{expression=" + expression + '}'; + } +} diff --git a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/ObjectProjectionExpression.java b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/ObjectProjectionExpression.java new file mode 100644 index 00000000000..316103cceae --- /dev/null +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/ObjectProjectionExpression.java @@ -0,0 +1,38 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.smithy.jmespath.ast; + +import software.amazon.smithy.jmespath.ExpressionVisitor; +import software.amazon.smithy.jmespath.JmespathExpression; + +/** + * A projection of object values. + */ +public final class ObjectProjectionExpression extends ProjectionExpression { + + public ObjectProjectionExpression(JmespathExpression left, JmespathExpression right) { + this(left, right, 1, 1); + } + + public ObjectProjectionExpression(JmespathExpression left, JmespathExpression right, int line, int column) { + super(left, right, line, column); + } + + @Override + public T accept(ExpressionVisitor visitor) { + return visitor.visitObjectProjection(this); + } +} diff --git a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/OrExpression.java b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/OrExpression.java new file mode 100644 index 00000000000..2b1d7fa67fc --- /dev/null +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/OrExpression.java @@ -0,0 +1,38 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.smithy.jmespath.ast; + +import software.amazon.smithy.jmespath.ExpressionVisitor; +import software.amazon.smithy.jmespath.JmespathExpression; + +/** + * Or expression that returns the expression that returns a truthy value. + */ +public final class OrExpression extends BinaryExpression { + + public OrExpression(JmespathExpression left, JmespathExpression right) { + this(left, right, 1, 1); + } + + public OrExpression(JmespathExpression left, JmespathExpression right, int line, int column) { + super(left, right, line, column); + } + + @Override + public T accept(ExpressionVisitor visitor) { + return visitor.visitOr(this); + } +} diff --git a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/ProjectionExpression.java b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/ProjectionExpression.java new file mode 100644 index 00000000000..7859899312f --- /dev/null +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/ProjectionExpression.java @@ -0,0 +1,39 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.smithy.jmespath.ast; + +import software.amazon.smithy.jmespath.ExpressionVisitor; +import software.amazon.smithy.jmespath.JmespathExpression; + +/** + * Iterates over each element in the array returned from the left expression, + * passes it to the right expression, and returns the aggregated results. + */ +public class ProjectionExpression extends BinaryExpression { + + public ProjectionExpression(JmespathExpression left, JmespathExpression right) { + this(left, right, 1, 1); + } + + public ProjectionExpression(JmespathExpression left, JmespathExpression right, int line, int column) { + super(left, right, line, column); + } + + @Override + public T accept(ExpressionVisitor visitor) { + return visitor.visitProjection(this); + } +} diff --git a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/SliceExpression.java b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/SliceExpression.java new file mode 100644 index 00000000000..42e3d78f594 --- /dev/null +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/SliceExpression.java @@ -0,0 +1,83 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.smithy.jmespath.ast; + +import java.util.Objects; +import java.util.OptionalInt; +import software.amazon.smithy.jmespath.ExpressionVisitor; +import software.amazon.smithy.jmespath.JmespathExpression; + +/** + * Represents a slice expression, containing an optional zero-based + * start offset, zero-based stop offset, and step. + */ +public final class SliceExpression extends JmespathExpression { + + private final Integer start; + private final Integer stop; + private final int step; + + public SliceExpression(Integer start, Integer stop, int step) { + this(start, stop, step, 1, 1); + } + + public SliceExpression(Integer start, Integer stop, int step, int line, int column) { + super(line, column); + this.start = start; + this.stop = stop; + this.step = step; + } + + @Override + public T accept(ExpressionVisitor visitor) { + return visitor.visitSlice(this); + } + + public OptionalInt getStart() { + return start == null ? OptionalInt.empty() : OptionalInt.of(start); + } + + public OptionalInt getStop() { + return stop == null ? OptionalInt.empty() : OptionalInt.of(stop); + } + + public int getStep() { + return step; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } else if (!(o instanceof SliceExpression)) { + return false; + } + SliceExpression sliceNode = (SliceExpression) o; + return Objects.equals(getStart(), sliceNode.getStart()) + && Objects.equals(getStop(), sliceNode.getStop()) + && getStep() == sliceNode.getStep(); + } + + @Override + public int hashCode() { + return Objects.hash(getStart(), getStop(), getStep()); + } + + @Override + public String toString() { + return "SliceExpression{start=" + start + ", stop=" + stop + ", step=" + step + '}'; + } +} diff --git a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/Subexpression.java b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/Subexpression.java new file mode 100644 index 00000000000..e5e90ad7306 --- /dev/null +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/Subexpression.java @@ -0,0 +1,38 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.smithy.jmespath.ast; + +import software.amazon.smithy.jmespath.ExpressionVisitor; +import software.amazon.smithy.jmespath.JmespathExpression; + +/** + * Visits the left expression and passes its result to the right expression. + */ +public final class Subexpression extends BinaryExpression { + + public Subexpression(JmespathExpression left, JmespathExpression right) { + this(left, right, 1, 1); + } + + public Subexpression(JmespathExpression left, JmespathExpression right, int line, int column) { + super(left, right, line, column); + } + + @Override + public T accept(ExpressionVisitor visitor) { + return visitor.visitSubexpression(this); + } +} diff --git a/smithy-jmespath/src/test/java/software/amazon/smithy/jmespath/LexerTest.java b/smithy-jmespath/src/test/java/software/amazon/smithy/jmespath/LexerTest.java new file mode 100644 index 00000000000..037506a4278 --- /dev/null +++ b/smithy-jmespath/src/test/java/software/amazon/smithy/jmespath/LexerTest.java @@ -0,0 +1,665 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.smithy.jmespath; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class LexerTest { + + private List tokenize(String expression) { + TokenIterator iterator = Lexer.tokenize(expression); + List tokens = new ArrayList<>(); + while (iterator.hasNext()) { + tokens.add(iterator.next()); + } + return tokens; + } + + @Test + public void tokenizesField() { + TokenIterator tokens = Lexer.tokenize("foo_123_FOO"); + + Token token = tokens.next(); + assertThat(token.type, equalTo(TokenType.IDENTIFIER)); + assertThat(token.value.asStringValue(), equalTo("foo_123_FOO")); + assertThat(token.line, equalTo(1)); + assertThat(token.column, equalTo(1)); + + token = tokens.next(); + assertThat(token.type, equalTo(TokenType.EOF)); + assertThat(token.line, equalTo(1)); + assertThat(token.column, equalTo(12)); + + assertThat(tokens.hasNext(), is(false)); + } + + @Test + public void tokenizesSubexpression() { + List tokens = tokenize("foo.bar"); + + assertThat(tokens, hasSize(4)); + assertThat(tokens.get(0).type, equalTo(TokenType.IDENTIFIER)); + assertThat(tokens.get(0).value.asStringValue(), equalTo("foo")); + assertThat(tokens.get(0).line, equalTo(1)); + assertThat(tokens.get(0).column, equalTo(1)); + + assertThat(tokens.get(1).type, equalTo(TokenType.DOT)); + assertThat(tokens.get(1).line, equalTo(1)); + assertThat(tokens.get(1).column, equalTo(4)); + + assertThat(tokens.get(2).type, equalTo(TokenType.IDENTIFIER)); + assertThat(tokens.get(2).value.asStringValue(), equalTo("bar")); + assertThat(tokens.get(2).line, equalTo(1)); + assertThat(tokens.get(2).column, equalTo(5)); + + assertThat(tokens.get(3).type, equalTo(TokenType.EOF)); + assertThat(tokens.get(3).line, equalTo(1)); + assertThat(tokens.get(3).column, equalTo(8)); + } + + @Test + public void tokenizesJsonArray() { + List tokens = tokenize("` [ 1 , true , false , null , -2 , \"hi\" ] `"); + + assertThat(tokens, hasSize(2)); + assertThat(tokens.get(0).type, equalTo(TokenType.LITERAL)); + assertThat(tokens.get(0).value.asArrayValue(), equalTo(Arrays.asList(1.0, true, false, null, -2.0, "hi"))); + assertThat(tokens.get(0).line, equalTo(1)); + assertThat(tokens.get(0).column, equalTo(1)); + + assertThat(tokens.get(1).type, equalTo(TokenType.EOF)); + assertThat(tokens.get(1).line, equalTo(1)); + assertThat(tokens.get(1).column, equalTo(50)); + } + + @Test + public void doesNotEatTrailingLiteralTick() { + Assertions.assertThrows(JmespathException.class, () -> tokenize("`true``")); + } + + @Test + public void tokenizesEmptyJsonArray() { + List tokens = tokenize("`[]`"); + + assertThat(tokens, hasSize(2)); + assertThat(tokens.get(0).type, equalTo(TokenType.LITERAL)); + assertThat(tokens.get(0).value.asArrayValue(), empty()); + assertThat(tokens.get(0).line, equalTo(1)); + assertThat(tokens.get(0).column, equalTo(1)); + + assertThat(tokens.get(1).type, equalTo(TokenType.EOF)); + assertThat(tokens.get(1).line, equalTo(1)); + assertThat(tokens.get(1).column, equalTo(5)); + } + + @Test + public void findsUnclosedJsonArrays() { + JmespathException e = Assertions.assertThrows(JmespathException.class, () -> Lexer.tokenize("`[`")); + + assertThat(e.getMessage(), containsString("Unclosed JSON array")); + } + + @Test + public void findsUnclosedJsonArrayLiteral() { + JmespathException e = Assertions.assertThrows(JmespathException.class, () -> Lexer.tokenize("`[")); + + assertThat(e.getMessage(), containsString("Unclosed JSON array")); + } + + @Test + public void doesNotSupportTrailingJsonArrayCommas() { + Assertions.assertThrows(JmespathException.class, () -> Lexer.tokenize("`[1,]")); + } + + @Test + public void detectsJsonArraySyntaxError() { + Assertions.assertThrows(JmespathException.class, () -> Lexer.tokenize("`[:]")); + } + + @Test + public void tokenizesJsonObject() { + List tokens = tokenize("`{\"foo\": true,\"bar\" : { \"bam\": [] } }`"); + + assertThat(tokens, hasSize(2)); + assertThat(tokens.get(0).type, equalTo(TokenType.LITERAL)); + Map obj = tokens.get(0).value.asObjectValue(); + assertThat(obj.entrySet(), hasSize(2)); + assertThat(obj.keySet(), contains("foo", "bar")); + assertThat(obj.get("foo"), equalTo(true)); + assertThat(obj.get("bar"), equalTo(Collections.singletonMap("bam", Collections.emptyList()))); + assertThat(tokens.get(0).line, equalTo(1)); + assertThat(tokens.get(0).column, equalTo(1)); + + assertThat(tokens.get(1).type, equalTo(TokenType.EOF)); + assertThat(tokens.get(1).line, equalTo(1)); + assertThat(tokens.get(1).column, equalTo(42)); + } + + @Test + public void tokenizesEmptyJsonObject() { + List tokens = tokenize("`{}`"); + + assertThat(tokens, hasSize(2)); + assertThat(tokens.get(0).type, equalTo(TokenType.LITERAL)); + assertThat(tokens.get(0).value.asObjectValue().entrySet(), empty()); + assertThat(tokens.get(0).line, equalTo(1)); + assertThat(tokens.get(0).column, equalTo(1)); + + assertThat(tokens.get(1).type, equalTo(TokenType.EOF)); + assertThat(tokens.get(1).line, equalTo(1)); + assertThat(tokens.get(1).column, equalTo(5)); + } + + @Test + public void findsUnclosedJsonObjects() { + JmespathException e = Assertions.assertThrows(JmespathException.class, () -> Lexer.tokenize("`{`")); + + assertThat(e.getMessage(), containsString("Unclosed JSON object")); + } + + @Test + public void findsUnclosedJsonObjectLiteral() { + JmespathException e = Assertions.assertThrows(JmespathException.class, () -> Lexer.tokenize("`{")); + + assertThat(e.getMessage(), containsString("Unclosed JSON object")); + } + + @Test + public void doesNotSupportTrailingJsonObjectCommas() { + Assertions.assertThrows(JmespathException.class, () -> Lexer.tokenize("`{\"foo\": true,}")); + } + + @Test + public void detectsJsonObjectSyntaxError() { + Assertions.assertThrows(JmespathException.class, () -> Lexer.tokenize("`{true:true}")); + } + + @Test + public void defendsAgainstTooMuchRecursionInObjects() { + StringBuilder text = new StringBuilder("`"); + for (int i = 0; i < 100; i++) { + text.append('{'); + } + + Assertions.assertThrows(JmespathException.class, () -> Lexer.tokenize(text.toString())); + } + + @Test + public void defendsAgainstTooMuchRecursionInArrays() { + StringBuilder text = new StringBuilder("`"); + for (int i = 0; i < 100; i++) { + text.append('['); + } + + Assertions.assertThrows(JmespathException.class, () -> Lexer.tokenize(text.toString())); + } + + @Test + public void canEscapeTicksInJsonLiteralStrings() { + List tokens = tokenize("`\"\\`\"`"); + + assertThat(tokens, hasSize(2)); + assertThat(tokens.get(0).type, equalTo(TokenType.LITERAL)); + assertThat(tokens.get(0).value.asStringValue(), equalTo("`")); + assertThat(tokens.get(0).line, equalTo(1)); + assertThat(tokens.get(0).column, equalTo(1)); + + assertThat(tokens.get(1).type, equalTo(TokenType.EOF)); + assertThat(tokens.get(1).line, equalTo(1)); + assertThat(tokens.get(1).column, equalTo(7)); + } + + @Test + public void cannotEscapeTicksOutsideOfJsonLiteral() { + JmespathException e = Assertions.assertThrows(JmespathException.class, () -> Lexer.tokenize("\"\\`\"")); + + assertThat(e.getMessage(), containsString("Invalid escape: `")); + } + + @Test + public void parsesQuotedString() { + List tokens = tokenize("\"foo\" \"\""); + + assertThat(tokens, hasSize(3)); + assertThat(tokens.get(0).type, equalTo(TokenType.IDENTIFIER)); + assertThat(tokens.get(0).value.asStringValue(), equalTo("foo")); + assertThat(tokens.get(0).line, equalTo(1)); + assertThat(tokens.get(0).column, equalTo(1)); + + assertThat(tokens.get(1).type, equalTo(TokenType.IDENTIFIER)); + assertThat(tokens.get(1).value.asStringValue(), equalTo("")); + assertThat(tokens.get(1).line, equalTo(1)); + assertThat(tokens.get(1).column, equalTo(7)); + + assertThat(tokens.get(2).type, equalTo(TokenType.EOF)); + assertThat(tokens.get(2).line, equalTo(1)); + assertThat(tokens.get(2).column, equalTo(9)); + } + + @Test + public void detectsUnclosedQuotes() { + Assertions.assertThrows(JmespathException.class, () -> Lexer.tokenize("\"")); + } + + @Test + public void parsesQuotedStringEscapes() { + List tokens = tokenize("\"\\\" \\n \\t \\r \\f \\b \\/ \\\\ \""); + + assertThat(tokens, hasSize(2)); + assertThat(tokens.get(0).type, equalTo(TokenType.IDENTIFIER)); + assertThat(tokens.get(0).value.asStringValue(), equalTo("\" \n \t \r \f \b / \\ ")); + assertThat(tokens.get(0).line, equalTo(1)); + assertThat(tokens.get(0).column, equalTo(1)); + + assertThat(tokens.get(1).type, equalTo(TokenType.EOF)); + assertThat(tokens.get(1).line, equalTo(1)); + assertThat(tokens.get(1).column, equalTo(27)); + } + + @Test + public void parsesQuotedStringValidHex() { + List tokens = tokenize("\"\\u000A\\u000a\""); + + assertThat(tokens, hasSize(2)); + assertThat(tokens.get(0).type, equalTo(TokenType.IDENTIFIER)); + assertThat(tokens.get(0).value.asStringValue(), equalTo("\n\n")); + assertThat(tokens.get(0).line, equalTo(1)); + assertThat(tokens.get(0).column, equalTo(1)); + + assertThat(tokens.get(1).type, equalTo(TokenType.EOF)); + assertThat(tokens.get(1).line, equalTo(1)); + assertThat(tokens.get(1).column, equalTo(15)); + } + + @Test + public void detectsTooShortHex() { + Assertions.assertThrows(JmespathException.class, () -> Lexer.tokenize("\"\\u0A\"")); + } + + @Test + public void detectsInvalidHex() { + Assertions.assertThrows(JmespathException.class, () -> Lexer.tokenize("\"\\u0L\"")); + } + + @Test + public void parsesLbrackets() { + List tokens = tokenize("[? [] ["); + + assertThat(tokens, hasSize(4)); + assertThat(tokens.get(0).type, equalTo(TokenType.FILTER)); + assertThat(tokens.get(0).line, equalTo(1)); + assertThat(tokens.get(0).column, equalTo(1)); + + assertThat(tokens.get(1).type, equalTo(TokenType.FLATTEN)); + assertThat(tokens.get(1).line, equalTo(1)); + assertThat(tokens.get(1).column, equalTo(4)); + + assertThat(tokens.get(2).type, equalTo(TokenType.LBRACKET)); + assertThat(tokens.get(2).line, equalTo(1)); + assertThat(tokens.get(2).column, equalTo(7)); + + assertThat(tokens.get(3).type, equalTo(TokenType.EOF)); + assertThat(tokens.get(3).line, equalTo(1)); + assertThat(tokens.get(3).column, equalTo(8)); + } + + @Test + public void parsesStar() { + List tokens = tokenize("*"); + + assertThat(tokens, hasSize(2)); + assertThat(tokens.get(0).type, equalTo(TokenType.STAR)); + assertThat(tokens.get(0).line, equalTo(1)); + assertThat(tokens.get(0).column, equalTo(1)); + } + + @Test + public void parsesPipeAndOr() { + List tokens = tokenize("| ||"); + + assertThat(tokens, hasSize(3)); + assertThat(tokens.get(0).type, equalTo(TokenType.PIPE)); + assertThat(tokens.get(0).line, equalTo(1)); + assertThat(tokens.get(0).column, equalTo(1)); + + assertThat(tokens.get(1).type, equalTo(TokenType.OR)); + assertThat(tokens.get(1).line, equalTo(1)); + assertThat(tokens.get(1).column, equalTo(3)); + } + + @Test + public void parsesAt() { + List tokens = tokenize("@"); + + assertThat(tokens, hasSize(2)); + assertThat(tokens.get(0).type, equalTo(TokenType.CURRENT)); + assertThat(tokens.get(0).line, equalTo(1)); + assertThat(tokens.get(0).column, equalTo(1)); + } + + @Test + public void parsesRbracketLbraceRbrace() { + List tokens = tokenize("]{}"); + + assertThat(tokens, hasSize(4)); + assertThat(tokens.get(0).type, equalTo(TokenType.RBRACKET)); + assertThat(tokens.get(0).line, equalTo(1)); + assertThat(tokens.get(0).column, equalTo(1)); + + assertThat(tokens.get(1).type, equalTo(TokenType.LBRACE)); + assertThat(tokens.get(1).line, equalTo(1)); + assertThat(tokens.get(1).column, equalTo(2)); + + assertThat(tokens.get(2).type, equalTo(TokenType.RBRACE)); + assertThat(tokens.get(2).line, equalTo(1)); + assertThat(tokens.get(2).column, equalTo(3)); + } + + @Test + public void parsesAmpersand() { + List tokens = tokenize("&"); + + assertThat(tokens, hasSize(2)); + assertThat(tokens.get(0).type, equalTo(TokenType.EXPREF)); + assertThat(tokens.get(0).line, equalTo(1)); + assertThat(tokens.get(0).column, equalTo(1)); + } + + @Test + public void parsesParens() { + List tokens = tokenize("()"); + + assertThat(tokens, hasSize(3)); + assertThat(tokens.get(0).type, equalTo(TokenType.LPAREN)); + assertThat(tokens.get(0).line, equalTo(1)); + assertThat(tokens.get(0).column, equalTo(1)); + + assertThat(tokens.get(1).type, equalTo(TokenType.RPAREN)); + assertThat(tokens.get(1).line, equalTo(1)); + assertThat(tokens.get(1).column, equalTo(2)); + } + + @Test + public void parsesCommasAndColons() { + List tokens = tokenize(",:"); + + assertThat(tokens, hasSize(3)); + assertThat(tokens.get(0).type, equalTo(TokenType.COMMA)); + assertThat(tokens.get(0).line, equalTo(1)); + assertThat(tokens.get(0).column, equalTo(1)); + + assertThat(tokens.get(1).type, equalTo(TokenType.COLON)); + assertThat(tokens.get(1).line, equalTo(1)); + assertThat(tokens.get(1).column, equalTo(2)); + } + + @Test + public void parsesValidEquals() { + List tokens = tokenize("=="); + + assertThat(tokens, hasSize(2)); + assertThat(tokens.get(0).type, equalTo(TokenType.EQUAL)); + assertThat(tokens.get(0).line, equalTo(1)); + assertThat(tokens.get(0).column, equalTo(1)); + + assertThat(tokens.get(1).type, equalTo(TokenType.EOF)); + assertThat(tokens.get(1).line, equalTo(1)); + assertThat(tokens.get(1).column, equalTo(3)); + } + + @Test + public void parsesInvalidEquals() { + Assertions.assertThrows(JmespathException.class, () -> Lexer.tokenize("=")); + } + + @Test + public void parsesGtLtNot() { + List tokens = tokenize("> >= < <= ! !="); + + assertThat(tokens, hasSize(7)); + assertThat(tokens.get(0).type, equalTo(TokenType.GREATER_THAN)); + assertThat(tokens.get(0).line, equalTo(1)); + assertThat(tokens.get(0).column, equalTo(1)); + + assertThat(tokens.get(1).type, equalTo(TokenType.GREATER_THAN_EQUAL)); + assertThat(tokens.get(1).line, equalTo(1)); + assertThat(tokens.get(1).column, equalTo(3)); + + assertThat(tokens.get(2).type, equalTo(TokenType.LESS_THAN)); + assertThat(tokens.get(2).line, equalTo(1)); + assertThat(tokens.get(2).column, equalTo(6)); + + assertThat(tokens.get(3).type, equalTo(TokenType.LESS_THAN_EQUAL)); + assertThat(tokens.get(3).line, equalTo(1)); + assertThat(tokens.get(3).column, equalTo(8)); + + assertThat(tokens.get(4).type, equalTo(TokenType.NOT)); + assertThat(tokens.get(4).line, equalTo(1)); + assertThat(tokens.get(4).column, equalTo(11)); + + assertThat(tokens.get(5).type, equalTo(TokenType.NOT_EQUAL)); + assertThat(tokens.get(5).line, equalTo(1)); + assertThat(tokens.get(5).column, equalTo(13)); + + assertThat(tokens.get(6).type, equalTo(TokenType.EOF)); + assertThat(tokens.get(6).line, equalTo(1)); + assertThat(tokens.get(6).column, equalTo(15)); + } + + @Test + public void parsesNumbers() { + List tokens = tokenize("123 -1 0.0"); + + assertThat(tokens, hasSize(4)); + assertThat(tokens.get(0).type, equalTo(TokenType.NUMBER)); + assertThat(tokens.get(0).value.asNumberValue().doubleValue(), equalTo(123.0)); + assertThat(tokens.get(0).line, equalTo(1)); + assertThat(tokens.get(0).column, equalTo(1)); + + assertThat(tokens.get(1).type, equalTo(TokenType.NUMBER)); + assertThat(tokens.get(1).value.asNumberValue().doubleValue(), equalTo(-1.0)); + assertThat(tokens.get(1).line, equalTo(1)); + assertThat(tokens.get(1).column, equalTo(5)); + + assertThat(tokens.get(2).type, equalTo(TokenType.NUMBER)); + assertThat(tokens.get(2).value.asNumberValue().doubleValue(), equalTo(0.0)); + assertThat(tokens.get(2).line, equalTo(1)); + assertThat(tokens.get(2).column, equalTo(8)); + + assertThat(tokens.get(3).type, equalTo(TokenType.EOF)); + assertThat(tokens.get(3).line, equalTo(1)); + assertThat(tokens.get(3).column, equalTo(11)); + } + + @Test + public void negativeMustBeFollowedByDigit() { + Assertions.assertThrows(JmespathException.class, () -> Lexer.tokenize("-")); + } + + @Test + public void decimalMustBeFollowedByDigit() { + Assertions.assertThrows(JmespathException.class, () -> Lexer.tokenize("0.a")); + } + + @Test + public void exponentMustBeFollowedByDigit() { + Assertions.assertThrows(JmespathException.class, () -> Lexer.tokenize("0.0ea")); + } + + @Test + public void ignoresNonNumericExponents() { + List tokens = tokenize("0.0a"); + + assertThat(tokens, hasSize(3)); + assertThat(tokens.get(0).type, equalTo(TokenType.NUMBER)); + assertThat(tokens.get(0).value.asNumberValue().doubleValue(), equalTo(0.0)); + assertThat(tokens.get(0).line, equalTo(1)); + assertThat(tokens.get(0).column, equalTo(1)); + + assertThat(tokens.get(1).type, equalTo(TokenType.IDENTIFIER)); + assertThat(tokens.get(1).value.asStringValue(), equalTo("a")); + assertThat(tokens.get(1).line, equalTo(1)); + assertThat(tokens.get(1).column, equalTo(4)); + + assertThat(tokens.get(2).type, equalTo(TokenType.EOF)); + assertThat(tokens.get(2).line, equalTo(1)); + assertThat(tokens.get(2).column, equalTo(5)); + } + + @Test + public void parsesComplexNumbers() { + List tokens = tokenize("123.009e+12 -001.109E-12"); + + assertThat(tokens, hasSize(3)); + assertThat(tokens.get(0).type, equalTo(TokenType.NUMBER)); + assertThat(tokens.get(0).value.asNumberValue().doubleValue(), equalTo(123.009e12)); + assertThat(tokens.get(0).line, equalTo(1)); + assertThat(tokens.get(0).column, equalTo(1)); + + assertThat(tokens.get(1).type, equalTo(TokenType.NUMBER)); + assertThat(tokens.get(1).value.asNumberValue().doubleValue(), equalTo(-001.109e-12)); + assertThat(tokens.get(1).line, equalTo(1)); + assertThat(tokens.get(1).column, equalTo(13)); + + assertThat(tokens.get(2).type, equalTo(TokenType.EOF)); + assertThat(tokens.get(2).line, equalTo(1)); + assertThat(tokens.get(2).column, equalTo(25)); + } + + @Test + public void detectsTopLevelInvalidSyntax() { + Assertions.assertThrows(JmespathException.class, () -> Lexer.tokenize("~")); + } + + @Test + public void parsesRawStringLiteral() { + List tokens = tokenize("'foo' 'foo\\'s' 'foo\\a'"); + + assertThat(tokens, hasSize(4)); + assertThat(tokens.get(0).type, equalTo(TokenType.LITERAL)); + assertThat(tokens.get(0).value.asStringValue(), equalTo("foo")); + assertThat(tokens.get(0).line, equalTo(1)); + assertThat(tokens.get(0).column, equalTo(1)); + + assertThat(tokens.get(1).type, equalTo(TokenType.LITERAL)); + assertThat(tokens.get(1).value.asStringValue(), equalTo("foo's")); + assertThat(tokens.get(1).line, equalTo(1)); + assertThat(tokens.get(1).column, equalTo(7)); + + assertThat(tokens.get(2).type, equalTo(TokenType.LITERAL)); + assertThat(tokens.get(2).value.asStringValue(), equalTo("foo\\a")); + assertThat(tokens.get(2).line, equalTo(1)); + assertThat(tokens.get(2).column, equalTo(16)); + + assertThat(tokens.get(3).type, equalTo(TokenType.EOF)); + assertThat(tokens.get(3).line, equalTo(1)); + assertThat(tokens.get(3).column, equalTo(23)); + } + + @Test + public void parsesEmptyRawString() { + List tokens = tokenize("''"); + + assertThat(tokens, hasSize(2)); + assertThat(tokens.get(0).type, equalTo(TokenType.LITERAL)); + assertThat(tokens.get(0).value.asStringValue(), equalTo("")); + assertThat(tokens.get(0).line, equalTo(1)); + assertThat(tokens.get(0).column, equalTo(1)); + + assertThat(tokens.get(1).type, equalTo(TokenType.EOF)); + assertThat(tokens.get(1).line, equalTo(1)); + assertThat(tokens.get(1).column, equalTo(3)); + } + + @Test + public void detectsUnclosedRawString() { + Assertions.assertThrows(JmespathException.class, () -> Lexer.tokenize("'foo")); + } + + @Test + public void convertsLexemeTokensToString() { + List tokens = tokenize("abc . : 10"); + + assertThat(tokens.get(0).toString(), equalTo("'abc'")); + assertThat(tokens.get(1).toString(), equalTo("'.'")); + assertThat(tokens.get(2).toString(), equalTo("':'")); + assertThat(tokens.get(3).toString(), equalTo("'10.0'")); + } + + @Test + public void tracksLineAndColumn() { + List tokens = tokenize(" abc\n .\n:\n10\r\na\rb"); + + assertThat(tokens.get(0).line, is(1)); + assertThat(tokens.get(0).column, is(2)); + + assertThat(tokens.get(1).line, is(2)); + assertThat(tokens.get(1).column, is(2)); + + assertThat(tokens.get(2).line, is(3)); + assertThat(tokens.get(2).column, is(1)); + + assertThat(tokens.get(3).toString(), equalTo("'10.0'")); + assertThat(tokens.get(3).line, is(4)); + assertThat(tokens.get(3).column, is(1)); + + assertThat(tokens.get(4).toString(), equalTo("'a'")); + assertThat(tokens.get(4).line, is(5)); + assertThat(tokens.get(4).column, is(1)); + + assertThat(tokens.get(5).toString(), equalTo("'b'")); + assertThat(tokens.get(5).line, is(6)); + assertThat(tokens.get(5).column, is(1)); + + assertThat(tokens.get(6).line, is(6)); + assertThat(tokens.get(6).column, is(2)); + } + + @Test + public void tokenizesRawStrings() { + List tokens = tokenize("starts_with(@, 'foo')"); + + assertThat(tokens.get(0).type, is(TokenType.IDENTIFIER)); + assertThat(tokens.get(1).type, is(TokenType.LPAREN)); + assertThat(tokens.get(2).type, is(TokenType.CURRENT)); + assertThat(tokens.get(3).type, is(TokenType.COMMA)); + assertThat(tokens.get(4).type, is(TokenType.LITERAL)); + assertThat(tokens.get(5).type, is(TokenType.RPAREN)); + assertThat(tokens.get(6).type, is(TokenType.EOF)); + } + + @Test + public void tokenizesQuotedIdentifier() { + List tokens = tokenize("\"1\""); + + assertThat(tokens.get(0).type, is(TokenType.IDENTIFIER)); + assertThat(tokens.get(1).type, is(TokenType.EOF)); + } +} diff --git a/smithy-jmespath/src/test/java/software/amazon/smithy/jmespath/ParserTest.java b/smithy-jmespath/src/test/java/software/amazon/smithy/jmespath/ParserTest.java new file mode 100644 index 00000000000..c42f0af359d --- /dev/null +++ b/smithy-jmespath/src/test/java/software/amazon/smithy/jmespath/ParserTest.java @@ -0,0 +1,386 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.smithy.jmespath; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; + +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.jmespath.ast.AndExpression; +import software.amazon.smithy.jmespath.ast.ComparatorType; +import software.amazon.smithy.jmespath.ast.ComparisonExpression; +import software.amazon.smithy.jmespath.ast.CurrentExpression; +import software.amazon.smithy.jmespath.ast.ExpressionReferenceExpression; +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; + +public class ParserTest { + @Test + public void throwsOnInvalidNudToken() { + JmespathException e = Assertions.assertThrows( + JmespathException.class,() -> JmespathExpression.parse("|| a")); + + assertThat(e.getMessage(), containsString("but found '||'")); + } + + @Test + public void parsesNudField() { + assertThat(JmespathExpression.parse("foo"), equalTo(new FieldExpression("foo"))); + } + + @Test + public void parsesFunctionExpression() { + assertThat(JmespathExpression.parse("length(@)"), equalTo( + new FunctionExpression("length", Collections.singletonList(new CurrentExpression())))); + } + + @Test + public void parsesFunctionWithMultipleArguments() { + assertThat(JmespathExpression.parse("starts_with(@, 'foo')"), equalTo( + new FunctionExpression("starts_with", Arrays.asList( + new CurrentExpression(), + new LiteralExpression("foo"))))); + } + + @Test + public void detectsIllegalTrailingCommaInFunctionExpression() { + JmespathException e = Assertions.assertThrows( + JmespathException.class, () -> JmespathExpression.parse("lenght(@,)")); + + assertThat(e.getMessage(), containsString("Invalid token after ',': ')'")); + } + + @Test + public void parsesNudWildcardIndex() { + assertThat(JmespathExpression.parse("[*]"), equalTo( + new ProjectionExpression( + new CurrentExpression(), + new CurrentExpression()))); + } + + @Test + public void parsesNudStar() { + assertThat(JmespathExpression.parse("*"), equalTo( + new ObjectProjectionExpression( + new CurrentExpression(), + new CurrentExpression()))); + } + + @Test + public void parsesNudLiteral() { + assertThat(JmespathExpression.parse("`true`"), equalTo(new LiteralExpression(true))); + } + + @Test + public void detectsTrailingLiteralTick() { + Assertions.assertThrows(JmespathException.class, () -> JmespathExpression.parse("`true``")); + } + + @Test + public void parsesNudIndex() { + assertThat(JmespathExpression.parse("[1]"), equalTo(new IndexExpression(1))); + } + + @Test + public void parsesNudFlatten() { + assertThat(JmespathExpression.parse("[].foo"), equalTo( + new ProjectionExpression( + new FlattenExpression(new CurrentExpression()), + new FieldExpression("foo")))); + } + + @Test + public void parsesNudMultiSelectList() { + assertThat(JmespathExpression.parse("[foo, bar]"), equalTo( + new MultiSelectListExpression(Arrays.asList( + new FieldExpression("foo"), + new FieldExpression("bar"))))); + } + + @Test + public void detectsIllegalTrailingCommaInNudMultiSelectList() { + JmespathException e = Assertions.assertThrows( + JmespathException.class, () -> JmespathExpression.parse("[foo,]")); + + assertThat(e.getMessage(), containsString("Invalid token after ',': ']'")); + } + + @Test + public void parsesNudMultiSelectHash() { + Map expressionMap = new LinkedHashMap<>(); + expressionMap.put("foo", new FieldExpression("bar")); + expressionMap.put("baz", new Subexpression(new FieldExpression("bam"), new FieldExpression("boo"))); + + assertThat(JmespathExpression.parse("{foo: bar, baz: bam.boo}"), equalTo( + new MultiSelectHashExpression(expressionMap))); + } + + @Test + public void parsesNudAmpersand() { + assertThat(JmespathExpression.parse("&foo[1]"), equalTo( + new ExpressionReferenceExpression( + new Subexpression( + new FieldExpression("foo"), + new IndexExpression(1))))); + } + + @Test + public void parsesNudNot() { + assertThat(JmespathExpression.parse("!foo[1]"), equalTo( + new NotExpression( + new Subexpression( + new FieldExpression("foo"), + new IndexExpression(1))))); + } + + @Test + public void parsesNudFilter() { + assertThat(JmespathExpression.parse("[?foo == `true`]"), equalTo( + new FilterProjectionExpression( + new CurrentExpression(), + new ComparisonExpression( + ComparatorType.EQUAL, + new FieldExpression("foo"), + new LiteralExpression(true)), + new CurrentExpression()))); + } + + @Test + public void parsesNudFilterWithComparators() { + for (ComparatorType type : ComparatorType.values()) { + assertThat(JmespathExpression.parse("[?foo " + type + " `true`]"), equalTo( + new FilterProjectionExpression( + new CurrentExpression(), + new ComparisonExpression( + type, + new FieldExpression("foo"), + new LiteralExpression(true)), + new CurrentExpression()))); + } + } + + @Test + public void parsesNudLparen() { + assertThat(JmespathExpression.parse("(foo | bar)"), equalTo( + new Subexpression( + new FieldExpression("foo"), + new FieldExpression("bar")))); + } + + @Test + public void parsesSubexpressions() { + assertThat(JmespathExpression.parse("foo.bar.baz"), equalTo( + new Subexpression( + new Subexpression( + new FieldExpression("foo"), + new FieldExpression("bar")), + new FieldExpression("baz")))); + } + + @Test + public void parsesSubexpressionsWithQuotedIdentifier() { + assertThat(JmespathExpression.parse("foo.\"1\""), equalTo( + new Subexpression(new FieldExpression("foo"), new FieldExpression("1")))); + } + + @Test + public void parsesMultiSelectHashAfterDot() { + assertThat(JmespathExpression.parse("foo.{bar: baz}"), equalTo( + new Subexpression( + new FieldExpression("foo"), + new MultiSelectHashExpression( + Collections.singletonMap("bar", new FieldExpression("baz")))))); + } + + @Test + public void parsesMultiSelectListAfterDot() { + assertThat(JmespathExpression.parse("foo.[bar]"), equalTo( + new Subexpression( + new FieldExpression("foo"), + new MultiSelectListExpression( + Collections.singletonList(new FieldExpression("bar")))))); + } + + @Test + public void requiresExpressionToFollowDot() { + JmespathException e = Assertions.assertThrows( + JmespathException.class, () -> JmespathExpression.parse("foo.")); + + assertThat(e.getMessage(), containsString("but found EOF")); + } + + @Test + public void parsesPipeExpressions() { + assertThat(JmespathExpression.parse("foo.bar.baz"), equalTo( + new Subexpression( + new Subexpression( + new FieldExpression("foo"), + new FieldExpression("bar")), + new FieldExpression("baz")))); + } + + @Test + public void parsesOrExpressions() { + assertThat(JmespathExpression.parse("foo || bar || baz"), equalTo( + new OrExpression( + new OrExpression( + new FieldExpression("foo"), + new FieldExpression("bar")), + new FieldExpression("baz")))); + } + + @Test + public void parsesAndExpressions() { + assertThat(JmespathExpression.parse("foo && bar && baz"), equalTo( + new AndExpression( + new AndExpression( + new FieldExpression("foo"), + new FieldExpression("bar")), + new FieldExpression("baz")))); + } + + @Test + public void parsesProjections() { + assertThat(JmespathExpression.parse("foo.*.bar[*] || baz"), equalTo( + new OrExpression( + new ObjectProjectionExpression( + new FieldExpression("foo"), + new ProjectionExpression( + new FieldExpression("bar"), + new CurrentExpression())), + new FieldExpression("baz")))); + } + + @Test + public void parsesLedFlattenProjection() { + assertThat(JmespathExpression.parse("a[].b"), equalTo( + new ProjectionExpression( + new FlattenExpression(new FieldExpression("a")), + new FieldExpression("b")))); + } + + @Test + public void parsesLedFilterProjection() { + assertThat(JmespathExpression.parse("a[?b > c].d"), equalTo( + new FilterProjectionExpression( + new FieldExpression("a"), + new ComparisonExpression( + ComparatorType.GREATER_THAN, + new FieldExpression("b"), + new FieldExpression("c")), + new FieldExpression("d")))); + } + + @Test + public void parsesLedProjectionIntoIndex() { + assertThat(JmespathExpression.parse("a.*[1].b"), equalTo( + new ObjectProjectionExpression( + new FieldExpression("a"), + new Subexpression( + new IndexExpression(1), + new FieldExpression("b"))))); + } + + @Test + public void parsesLedProjectionIntoFilterProjection() { + assertThat(JmespathExpression.parse("a.*[?foo == bar]"), equalTo( + new ObjectProjectionExpression( + new FieldExpression("a"), + new FilterProjectionExpression( + new CurrentExpression(), + new ComparisonExpression( + ComparatorType.EQUAL, + new FieldExpression("foo"), + new FieldExpression("bar")), + new CurrentExpression())))); + } + + @Test + public void validatesValidLedProjectionRhs() { + JmespathException e = Assertions.assertThrows( + JmespathException.class, () -> JmespathExpression.parse("a.**")); + + assertThat(e.getMessage(), containsString("Invalid projection")); + } + + @Test + public void parsesSlices() { + assertThat(JmespathExpression.parse("[1:3].foo"), equalTo( + new ProjectionExpression( + new SliceExpression(1, 3, 1), + new FieldExpression("foo")))); + } + + @Test + public void parsesSlicesWithStep() { + assertThat(JmespathExpression.parse("[5:10:2]"), equalTo( + new ProjectionExpression( + new SliceExpression(5, 10, 2), + new CurrentExpression()))); + } + + @Test + public void parsesSlicesWithNegativeStep() { + assertThat(JmespathExpression.parse("[10:5:-1]"), equalTo( + new ProjectionExpression( + new SliceExpression(10, 5, -1), + new CurrentExpression()))); + } + + @Test + public void parsesSlicesWithStepAndNoStop() { + assertThat(JmespathExpression.parse("[10::5]"), equalTo( + new ProjectionExpression( + new SliceExpression(10, null, 5), + new CurrentExpression()))); + } + + @Test + public void parsesSlicesWithStartAndNoStepOrEnd() { + assertThat(JmespathExpression.parse("[10::]"), equalTo( + new ProjectionExpression( + new SliceExpression(10, null, 1), + new CurrentExpression()))); + + assertThat(JmespathExpression.parse("[10:]"), equalTo( + new ProjectionExpression( + new SliceExpression(10, null, 1), + new CurrentExpression()))); + } + + @Test + public void validatesTooManyColonsInSlice() { + Assertions.assertThrows(JmespathException.class, () -> JmespathExpression.parse("[10:::]")); + } +} diff --git a/smithy-jmespath/src/test/java/software/amazon/smithy/jmespath/RunnerTest.java b/smithy-jmespath/src/test/java/software/amazon/smithy/jmespath/RunnerTest.java new file mode 100644 index 00000000000..c382fcd5a08 --- /dev/null +++ b/smithy-jmespath/src/test/java/software/amazon/smithy/jmespath/RunnerTest.java @@ -0,0 +1,60 @@ +package software.amazon.smithy.jmespath; + +import java.io.BufferedReader; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +/** + * This test loads invalid and valid files, to ensure that they + * are either able to be parsed or not able to be parsed. + */ +public class RunnerTest { + @Test + public void validTests() { + for (String line : readFile(getClass().getResourceAsStream("valid"))) { + try { + JmespathExpression expression = JmespathExpression.parse(line); + for (ExpressionProblem problem : expression.lint().getProblems()) { + if (problem.severity == ExpressionProblem.Severity.ERROR) { + Assertions.fail("Did not expect an ERROR for line: " + line + "\n" + problem); + } else { + System.out.println(problem); + } + } + } catch (JmespathException e) { + Assertions.fail("Error loading line:\n" + line + "\n" + e.getMessage(), e); + } + } + } + + @Test + public void invalidTests() { + for (String line : readFile(getClass().getResourceAsStream("invalid"))) { + try { + JmespathExpression.parse(line); + Assertions.fail("Expected line to fail: " + line); + } catch (JmespathException e) { + // pass + } + } + } + + private List readFile(InputStream stream) { + return new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8)) + .lines() + .map(line -> { + if (line.endsWith(",")) { + return line.substring(0, line.length() - 1); + } else { + return line; + } + }) + .map(line -> Lexer.tokenize(line).next().value.asStringValue()) + .collect(Collectors.toList()); + } +} diff --git a/smithy-jmespath/src/test/java/software/amazon/smithy/jmespath/TokenIteratorTest.java b/smithy-jmespath/src/test/java/software/amazon/smithy/jmespath/TokenIteratorTest.java new file mode 100644 index 00000000000..65defeb24b3 --- /dev/null +++ b/smithy-jmespath/src/test/java/software/amazon/smithy/jmespath/TokenIteratorTest.java @@ -0,0 +1,159 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.smithy.jmespath; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.nullValue; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.NoSuchElementException; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class TokenIteratorTest { + @Test + public void peeksAndIterates() { + List tokens = Arrays.asList( + new Token(TokenType.DOT, null, 1, 1), + new Token(TokenType.STAR, null, 1, 2), + new Token(TokenType.FLATTEN, null, 1, 3)); + TokenIterator iterator = new TokenIterator(tokens); + + assertThat(iterator.hasNext(), is(true)); + assertThat(iterator.peek(), equalTo(tokens.get(0))); + assertThat(iterator.next(), equalTo(tokens.get(0))); + + assertThat(iterator.hasNext(), is(true)); + assertThat(iterator.peek(), equalTo(tokens.get(1))); + assertThat(iterator.next(), equalTo(tokens.get(1))); + + assertThat(iterator.hasNext(), is(true)); + assertThat(iterator.peek(), equalTo(tokens.get(2))); + assertThat(iterator.next(), equalTo(tokens.get(2))); + + assertThat(iterator.hasNext(), is(false)); + assertThat(iterator.peek(), nullValue()); + } + + @Test + public void throwsWhenNoMoreTokens() { + TokenIterator iterator = new TokenIterator(Collections.emptyList()); + Assertions.assertThrows(NoSuchElementException.class, iterator::next); + } + + @Test + public void peeksAhead() { + List tokens = Arrays.asList( + new Token(TokenType.DOT, null, 1, 1), + new Token(TokenType.STAR, null, 1, 2), + new Token(TokenType.FLATTEN, null, 1, 3)); + TokenIterator iterator = new TokenIterator(tokens); + + assertThat(iterator.peek(), equalTo(tokens.get(0))); + assertThat(iterator.peek(1), equalTo(tokens.get(1))); + assertThat(iterator.peek(2), equalTo(tokens.get(2))); + assertThat(iterator.peek(3), nullValue()); + } + + @Test + public void expectsTokensWithValidResults() { + List tokens = Arrays.asList( + new Token(TokenType.DOT, null, 1, 1), + new Token(TokenType.STAR, null, 1, 2), + new Token(TokenType.FLATTEN, null, 1, 3)); + TokenIterator iterator = new TokenIterator(tokens); + + assertThat(iterator.expect(TokenType.DOT), equalTo(tokens.get(0))); + assertThat(iterator.expect(TokenType.IDENTIFIER, TokenType.STAR), equalTo(tokens.get(1))); + assertThat(iterator.expect(TokenType.FLATTEN), equalTo(tokens.get(2))); + } + + @Test + public void expectsTokensWithInvalidResultBecauseEmpty() { + TokenIterator iterator = new TokenIterator(Collections.emptyList()); + JmespathException e = Assertions.assertThrows( + JmespathException.class, + () -> iterator.expect(TokenType.DOT)); + + assertThat(e.getMessage(), + equalTo("Syntax error at line 1 column 1: Expected '.', but found EOF")); + } + + @Test + public void expectsOneOrMoreTokensWithInvalidResultBecauseEmpty() { + TokenIterator iterator = new TokenIterator(Collections.emptyList()); + JmespathException e = Assertions.assertThrows( + JmespathException.class, + () -> iterator.expect(TokenType.DOT, TokenType.EXPREF)); + + assertThat(e.getMessage(), + equalTo("Syntax error at line 1 column 1: Expected ['.', '&'], but found EOF")); + } + + @Test + public void expectsTokensWithInvalidResultBecauseEof() { + List tokens = Collections.singletonList(new Token(TokenType.DOT, null, 1, 1)); + TokenIterator iterator = new TokenIterator(tokens); + iterator.next(); + JmespathException e = Assertions.assertThrows( + JmespathException.class, + () -> iterator.expect(TokenType.DOT)); + + assertThat(e.getMessage(), + equalTo("Syntax error at line 1 column 1: Expected '.', but found EOF")); + } + + @Test + public void expectsOneOrMoreTokensWithInvalidResultBecauseEof() { + List tokens = Collections.singletonList(new Token(TokenType.DOT, null, 1, 1)); + TokenIterator iterator = new TokenIterator(tokens); + iterator.next(); + JmespathException e = Assertions.assertThrows( + JmespathException.class, + () -> iterator.expect(TokenType.DOT, TokenType.EXPREF)); + + assertThat(e.getMessage(), + equalTo("Syntax error at line 1 column 1: Expected ['.', '&'], but found EOF")); + } + + @Test + public void expectsTokensWithInvalidResultBecauseWrongType() { + List tokens = Collections.singletonList(new Token(TokenType.DOT, null, 1, 1)); + TokenIterator iterator = new TokenIterator(tokens); + JmespathException e = Assertions.assertThrows( + JmespathException.class, + () -> iterator.expect(TokenType.STAR)); + + assertThat(e.getMessage(), + equalTo("Syntax error at line 1 column 1: Expected '*', but found '.'")); + } + + @Test + public void expectsOneOrMoreTokensWithInvalidResultBecauseWrongType() { + List tokens = Collections.singletonList(new Token(TokenType.DOT, null, 1, 1)); + TokenIterator iterator = new TokenIterator(tokens); + JmespathException e = Assertions.assertThrows( + JmespathException.class, + () -> iterator.expect(TokenType.STAR, TokenType.EXPREF)); + + assertThat(e.getMessage(), + equalTo("Syntax error at line 1 column 1: Expected ['*', '&'], but found '.'")); + } +} diff --git a/smithy-jmespath/src/test/java/software/amazon/smithy/jmespath/TypeCheckerTest.java b/smithy-jmespath/src/test/java/software/amazon/smithy/jmespath/TypeCheckerTest.java new file mode 100644 index 00000000000..f4dbdf60862 --- /dev/null +++ b/smithy-jmespath/src/test/java/software/amazon/smithy/jmespath/TypeCheckerTest.java @@ -0,0 +1,373 @@ +package software.amazon.smithy.jmespath; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.empty; + +import java.util.List; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Test; + +public class TypeCheckerTest { + + private List check(String expr) { + return JmespathExpression.parse(expr).lint().getProblems().stream() + .map(ExpressionProblem::toString) + .collect(Collectors.toList()); + } + + @Test + public void detectsInvalidArrayProjectionLhs() { + assertThat(check("{foo: `true`} | [*]"), contains("[DANGER] Array projection performed on object (1:18)")); + } + + @Test + public void detectsInvalidObjectProjectionLhs() { + assertThat(check("[foo] | *"), contains("[DANGER] Object projection performed on array (1:9)")); + } + + @Test + public void detectsGettingFieldFromArray() { + assertThat(check("[foo].baz"), contains("[DANGER] Object field 'baz' extraction performed on array (1:7)")); + } + + @Test + public void detectsFlatteningNonArray() { + assertThat(check("`true` | []"), contains("[DANGER] Array flatten performed on boolean (1:10)")); + } + + @Test + public void detectsBadFlattenExpression() { + assertThat(check("[].[`true` | foo]"), contains("[DANGER] Object field 'foo' extraction performed on boolean (1:14)")); + } + + @Test + public void detectsInvalidExpressionsInMultiSelectLists() { + assertThat(check("`true` | [foo, [1], {bar: foo}]"), containsInAnyOrder( + "[DANGER] Object field 'foo' extraction performed on boolean (1:11)", + "[DANGER] Array index '1' extraction performed on boolean (1:17)", + "[DANGER] Object field 'foo' extraction performed on boolean (1:27)")); + } + + @Test + public void detectsInvalidExpressionsInMultiSelectHash() { + assertThat(check("`true` | {foo: [1], bar: foo}"), containsInAnyOrder( + "[DANGER] Array index '1' extraction performed on boolean (1:17)", + "[DANGER] Object field 'foo' extraction performed on boolean (1:26)")); + } + + @Test + public void detectsInvalidComparisonExpressions() { + assertThat(check("`true` | foo == [1]"), containsInAnyOrder( + "[DANGER] Object field 'foo' extraction performed on boolean (1:10)", + "[DANGER] Array index '1' extraction performed on boolean (1:18)")); + } + + @Test + public void detectsInvalidExpressionReferences() { + assertThat(check("&(`true` | foo)"), containsInAnyOrder( + "[DANGER] Object field 'foo' extraction performed on boolean (1:12)")); + } + + @Test + public void detectsValidIndex() { + assertThat(check("`[1]` | [1]"), empty()); + } + + @Test + public void detectsValidField() { + assertThat(check("`{\"foo\": true}` | foo"), empty()); + } + + @Test + public void detectsInvalidAndLhs() { + assertThat(check("(`true` | foo) && baz"), containsInAnyOrder( + "[DANGER] Object field 'foo' extraction performed on boolean (1:11)")); + } + + @Test + public void detectsInvalidAndRhs() { + assertThat(check("foo && (`true` | foo)"), containsInAnyOrder( + "[DANGER] Object field 'foo' extraction performed on boolean (1:18)")); + } + + @Test + public void detectsInvalidOrLhs() { + assertThat(check("(`true` | foo) || baz"), containsInAnyOrder( + "[DANGER] Object field 'foo' extraction performed on boolean (1:11)")); + } + + @Test + public void detectsInvalidOrRhs() { + assertThat(check("foo || (`true` | foo)"), containsInAnyOrder( + "[DANGER] Object field 'foo' extraction performed on boolean (1:18)")); + } + + @Test + public void detectsInvalidNot() { + assertThat(check("`true` | !foo"), containsInAnyOrder( + "[DANGER] Object field 'foo' extraction performed on boolean (1:11)")); + } + + @Test + public void detectsValidNot() { + assertThat(check("`{\"foo\": true}` | !foo"), empty()); + } + + @Test + public void detectsMissingProperty() { + assertThat(check("`{}` | foo"), containsInAnyOrder( + "[DANGER] Object field 'foo' does not exist in object with properties [] (1:8)")); + } + + @Test + public void detectsInvalidSlice() { + assertThat(check("`true` | [1:10]"), containsInAnyOrder( + "[DANGER] Slice performed on boolean (1:11)")); + } + + @Test + public void detectsValidSlice() { + assertThat(check("`[]` | [1:10]"), empty()); + } + + @Test + public void detectsInvalidFilterProjectionLhs() { + assertThat(check("`true` | [?baz == bar]"), containsInAnyOrder( + "[DANGER] Filter projection performed on boolean (1:19)")); + } + + @Test + public void detectsInvalidFilterProjectionRhs() { + assertThat(check("[?baz == bar].[`true` | bam]"), containsInAnyOrder( + "[DANGER] Object field 'bam' extraction performed on boolean (1:25)")); + } + + @Test + public void detectsInvalidFilterProjectionComparison() { + assertThat(check("[?(`true` | baz) == bar]"), containsInAnyOrder( + "[DANGER] Object field 'baz' extraction performed on boolean (1:13)")); + } + + @Test + public void detectsInvalidFunction() { + assertThat(check("does_not_exist(@)"), containsInAnyOrder("[ERROR] Unknown function: does_not_exist (1:1)")); + } + + @Test + public void detectsInvalidFunctionArity() { + assertThat(check("length(@, @)"), containsInAnyOrder( + "[ERROR] length function expected 1 arguments, but was given 2 (1:1)")); + } + + @Test + public void detectsSuccessfulAnyArgument() { + assertThat(check("length(@)"), empty()); + assertThat(check("starts_with(@, @)"), empty()); + assertThat(check("ends_with(@, @)"), empty()); + assertThat(check("avg(@)"), empty()); + } + + @Test + public void detectsSuccessfulStaticArguments() { + assertThat(check("length('foo')"), empty()); + assertThat(check("starts_with('foo', 'f')"), empty()); + assertThat(check("ends_with('foo', 'o')"), empty()); + assertThat(check("avg(`[10, 15]`)"), empty()); + } + + @Test + public void detectsInvalidStaticArguments() { + assertThat(check("length(`true`)"), containsInAnyOrder( + "[ERROR] length function argument 0 error: Expected one of [string, array, object], but found boolean (1:8)")); + assertThat(check("starts_with(`true`, `false`)"), containsInAnyOrder( + "[ERROR] starts_with function argument 0 error: Expected argument to be string, but found boolean (1:13)", + "[ERROR] starts_with function argument 1 error: Expected argument to be string, but found boolean (1:21)")); + assertThat(check("avg(`[\"a\", false]`)"), containsInAnyOrder( + "[ERROR] avg function argument 0 error: Expected an array of number, but found string at index 0 (1:5)")); + } + + @Test + public void detectsInvalidArgumentThatExpectedArray() { + assertThat(check("avg(`true`)"), containsInAnyOrder( + "[ERROR] avg function argument 0 error: Expected argument to be an array, but found boolean (1:5)")); + } + + @Test + public void detectsInvalidUseOfStaticObjects() { + assertThat(check("{foo: `true`}.length(foo)"), containsInAnyOrder( + "[ERROR] length function argument 0 error: Expected one of [string, array, object], but found boolean (1:22)")); + assertThat(check("{foo: `true`} | floor(@)"), containsInAnyOrder( + "[ERROR] floor function argument 0 error: Expected argument to be number, but found object (1:23)")); + } + + @Test + public void detectsWhenTooFewArgumentsAreGiven() { + assertThat(check("length()"), containsInAnyOrder( + "[ERROR] length function expected 1 arguments, but was given 0 (1:1)")); + } + + @Test + public void parsesVariadicFunctionsProperly() { + assertThat(check("not_null(@, @, @, @, @)"), empty()); + } + + @Test + public void unknownOrResultIsPermittedAsAny() { + assertThat(check("length(a || b)"), empty()); + assertThat(check("length(a || `true`)"), empty()); + } + + @Test + public void unknownAndResultIsPermittedAsAny() { + assertThat(check("length(a && b)"), empty()); + } + + @Test + public void detectsInvalidAndResult() { + assertThat(check("length(a && `true`)"), contains( + "[ERROR] length function argument 0 error: Expected one of [string, array, object], but found boolean (1:10)")); + } + + @Test + public void andForwardsTruthyValuesThrough() { + assertThat(check("`true` && `true` == `true`"), empty()); + } + + @Test + public void flattenFiltersOutNullValues() { + assertThat(check("`[null, \"hello\", null, \"goodbye\"]`[] | length([0]) || length([1])"), empty()); + } + + @Test + public void flattenFiltersOutNullValuesAndMergesArrays() { + assertThat(check("`[null, [\"hello\"], null, [\"goodbye\"]]`[] | length([0]) || length([1])"), empty()); + } + + @Test + public void canDetectInvalidIndexResultsStatically() { + assertThat(check("`[null, true]` | length([0]) || length([1])"), containsInAnyOrder( + "[ERROR] length function argument 0 error: Expected one of [string, array, object], but found null (1:26)", + "[ERROR] length function argument 0 error: Expected one of [string, array, object], but found boolean (1:41)")); + } + + @Test + public void analyzesValidObjectProjectionRhs() { + assertThat(check("`{\"foo\": [\"hi\"]}`.*.nope"), containsInAnyOrder( + "[DANGER] Object field 'nope' extraction performed on array (1:21)")); + } + + @Test + public void detectsInvalidObjectProjectionRhs() { + assertThat(check("`{\"foo\": [true]}`.*[0].length(@)"), containsInAnyOrder( + "[ERROR] length function argument 0 error: Expected one of [string, array, object], but found boolean (1:31)")); + } + + @Test + public void detectsInvalidFilterProjectionRhsFunction() { + assertThat(check("`[{\"foo\": true}, {\"foo\": false}]`[?foo == `true`].foo | length([0])"), containsInAnyOrder( + "[ERROR] length function argument 0 error: Expected one of [string, array, object], but found boolean (1:65)")); + } + + @Test + public void comparesBooleans() { + assertThat(check("`[{\"foo\": true}, {\"foo\": false}]`[?foo == `true`] | length(to_string([0]))"), empty()); + assertThat(check("`[{\"foo\": true}, {\"foo\": false}]`[?foo != `true`] | length(to_string([0]))"), empty()); + assertThat(check("`[{\"foo\": true}, {\"foo\": false}]`[?foo < `true`] | length([0])"), containsInAnyOrder( + "[WARNING] Invalid comparator '<' for boolean (1:42)", + "[ERROR] length function argument 0 error: Expected one of [string, array, object], but found null (1:60)")); + } + + @Test + public void comparesStrings() { + assertThat(check("`[{\"foo\": \"a\"}, {\"foo\": \"b\"}]`[?foo == 'a'] | length(to_string([0]))"), empty()); + assertThat(check("`[{\"foo\": \"a\"}, {\"foo\": \"b\"}]`[?foo != 'a'] | length(to_string([0]))"), empty()); + assertThat(check("`[{\"foo\": \"a\"}, {\"foo\": \"b\"}]`[?foo > 'a'] | length([0])"), containsInAnyOrder( + "[WARNING] Invalid comparator '>' for string (1:39)", + "[ERROR] length function argument 0 error: Expected one of [string, array, object], but found null (1:54)")); + } + + @Test + public void comparesNumbers() { + assertThat(check("`[{\"foo\": 1}, {\"foo\": 2}]`[?foo == `1`].foo | abs([0])"), empty()); + assertThat(check("`[{\"foo\": 1}, {\"foo\": 2}]`[?foo != `1`].foo | abs([0])"), empty()); + assertThat(check("`[{\"foo\": 1}, {\"foo\": 2}]`[?foo > `1`].foo | abs([0])"), empty()); + assertThat(check("`[{\"foo\": 1}, {\"foo\": 2}]`[?foo >= `1`].foo | abs([0])"), empty()); + assertThat(check("`[{\"foo\": 1}, {\"foo\": 2}]`[?foo < `2`].foo | abs([0])"), empty()); + assertThat(check("`[{\"foo\": 1}, {\"foo\": 2}]`[?foo <= `2`].foo | abs([0])"), empty()); + assertThat(check("`[{\"foo\": 1}, {\"foo\": 2}]`[?foo < `0`].foo | abs([0])"), containsInAnyOrder( + "[ERROR] abs function argument 0 error: Expected argument to be number, but found null (1:51)")); + } + + @Test + public void comparisonsBetweenIncompatibleTypesIsFalse() { + assertThat(check("`[{\"foo\": 1}, {\"foo\": 2}]`[?foo == `true`].foo | abs([0])"), containsInAnyOrder( + "[ERROR] abs function argument 0 error: Expected argument to be number, but found null (1:55)")); + } + + @Test + public void comparesNulls() { + assertThat(check("length(`null` == `null` && 'hi')"), empty()); + assertThat(check("length(`null` != `null` || 'hi')"), empty()); + assertThat(check("length(`null` != `null` && 'hi')"), contains( + "[ERROR] length function argument 0 error: Expected one of [string, array, object], but found null (1:25)")); + assertThat(check("length(`null` > `null` && 'hi')"), containsInAnyOrder( + "[WARNING] Invalid comparator '>' for null (1:17)", + "[ERROR] length function argument 0 error: Expected one of [string, array, object], but found null (1:24)")); + assertThat(check("length(`null` >= `null` && 'hi')"), containsInAnyOrder( + "[WARNING] Invalid comparator '>=' for null (1:18)", + "[ERROR] length function argument 0 error: Expected one of [string, array, object], but found null (1:25)")); + assertThat(check("length(`null` < `null` && 'hi')"), containsInAnyOrder( + "[WARNING] Invalid comparator '<' for null (1:17)", + "[ERROR] length function argument 0 error: Expected one of [string, array, object], but found null (1:24)")); + assertThat(check("length(`null` <= `null` && 'hi')"), containsInAnyOrder( + "[WARNING] Invalid comparator '<=' for null (1:18)", + "[ERROR] length function argument 0 error: Expected one of [string, array, object], but found null (1:25)")); + } + + @Test + public void cannotCompareExpref() { + assertThat(check("(&foo) == (&foo)"), contains("[WARNING] Invalid comparator '==' for expression_reference (1:11)")); + } + + @Test + public void comparesArrays() { + assertThat(check("length(`[1,2]` == `[1,2]` && 'hi')"), empty()); + assertThat(check("length(`[1]` != `[1,2]` && 'hi')"), empty()); + assertThat(check("length(`[1]` != `[1]` && 'hi')"), contains( + "[ERROR] length function argument 0 error: Expected one of [string, array, object], but found null (1:23)")); + assertThat(check("length(`[1]` > `[2]` && 'hi')"), containsInAnyOrder( + "[WARNING] Invalid comparator '>' for array (1:16)", + "[ERROR] length function argument 0 error: Expected one of [string, array, object], but found null (1:22)")); + assertThat(check("length(`[1]` >= `[2]` && 'hi')"), containsInAnyOrder( + "[WARNING] Invalid comparator '>=' for array (1:17)", + "[ERROR] length function argument 0 error: Expected one of [string, array, object], but found null (1:23)")); + assertThat(check("length(`[1]` < `[2]` && 'hi')"), containsInAnyOrder( + "[WARNING] Invalid comparator '<' for array (1:16)", + "[ERROR] length function argument 0 error: Expected one of [string, array, object], but found null (1:22)")); + assertThat(check("length(`[1]` <= `[2]` && 'hi')"), containsInAnyOrder( + "[WARNING] Invalid comparator '<=' for array (1:17)", + "[ERROR] length function argument 0 error: Expected one of [string, array, object], but found null (1:23)")); + } + + @Test + public void comparesObjects() { + assertThat(check("length(`{}` == `{}` && 'hi')"), empty()); + assertThat(check("length(`{\"foo\":true}` != `{}` && 'hi')"), empty()); + assertThat(check("length(`[1]` != `[1]` && 'hi')"), contains( + "[ERROR] length function argument 0 error: Expected one of [string, array, object], but found null (1:23)")); + assertThat(check("length(`{\"foo\":true}` > `{}` && 'hi')"), containsInAnyOrder( + "[WARNING] Invalid comparator '>' for object (1:25)", + "[ERROR] length function argument 0 error: Expected one of [string, array, object], but found null (1:30)")); + assertThat(check("length(`{\"foo\":true}` >= `{}` && 'hi')"), containsInAnyOrder( + "[WARNING] Invalid comparator '>=' for object (1:26)", + "[ERROR] length function argument 0 error: Expected one of [string, array, object], but found null (1:31)")); + assertThat(check("length(`{\"foo\":true}` < `{}` && 'hi')"), containsInAnyOrder( + "[WARNING] Invalid comparator '<' for object (1:25)", + "[ERROR] length function argument 0 error: Expected one of [string, array, object], but found null (1:30)")); + assertThat(check("length(`{\"foo\":true}` <= `{}` && 'hi')"), containsInAnyOrder( + "[WARNING] Invalid comparator '<=' for object (1:26)", + "[ERROR] length function argument 0 error: Expected one of [string, array, object], but found null (1:31)")); + } +} diff --git a/smithy-jmespath/src/test/java/software/amazon/smithy/jmespath/ast/LiteralExpressionTest.java b/smithy-jmespath/src/test/java/software/amazon/smithy/jmespath/ast/LiteralExpressionTest.java new file mode 100644 index 00000000000..ecdccb370ac --- /dev/null +++ b/smithy-jmespath/src/test/java/software/amazon/smithy/jmespath/ast/LiteralExpressionTest.java @@ -0,0 +1,161 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.smithy.jmespath.ast; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; + +import java.util.Arrays; +import java.util.Collections; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.jmespath.JmespathException; +import software.amazon.smithy.jmespath.RuntimeType; + +public class LiteralExpressionTest { + @Test + public void containsNullValues() { + LiteralExpression node = new LiteralExpression(null); + + assertThat(node.isNullValue(), is(true)); + assertThat(node.getType(), equalTo(RuntimeType.NULL)); + } + + @Test + public void throwsWhenNotString() { + LiteralExpression node = new LiteralExpression(10); + + Assertions.assertThrows(JmespathException.class, node::asStringValue); + } + + @Test + public void getsAsString() { + LiteralExpression node = new LiteralExpression("foo"); + + node.asStringValue(); + assertThat(node.isStringValue(), is(true)); + assertThat(node.isNullValue(), is(false)); // not null + assertThat(node.getType(), equalTo(RuntimeType.STRING)); + } + + @Test + public void throwsWhenNotArray() { + LiteralExpression node = new LiteralExpression("hi"); + + Assertions.assertThrows(JmespathException.class, node::asArrayValue); + } + + @Test + public void getsAsArray() { + LiteralExpression node = new LiteralExpression(Collections.emptyList()); + + node.asArrayValue(); + assertThat(node.isArrayValue(), is(true)); + assertThat(node.getType(), equalTo(RuntimeType.ARRAY)); + } + + @Test + public void getsNegativeArrayIndex() { + LiteralExpression node = new LiteralExpression(Arrays.asList(1, 2, 3)); + + assertThat(node.getArrayIndex(-1).getValue(), equalTo(3)); + assertThat(node.getArrayIndex(-2).getValue(), equalTo(2)); + assertThat(node.getArrayIndex(-3).getValue(), equalTo(1)); + assertThat(node.getArrayIndex(-4).getValue(), equalTo(null)); + } + + @Test + public void throwsWhenNotNumber() { + LiteralExpression node = new LiteralExpression("hi"); + + Assertions.assertThrows(JmespathException.class, node::asNumberValue); + } + + @Test + public void getsAsNumber() { + LiteralExpression node = new LiteralExpression(10); + + node.asNumberValue(); + assertThat(node.isNumberValue(), is(true)); + assertThat(node.getType(), equalTo(RuntimeType.NUMBER)); + } + + @Test + public void throwsWhenNotBoolean() { + LiteralExpression node = new LiteralExpression("hi"); + + Assertions.assertThrows(JmespathException.class, node::asBooleanValue); + } + + @Test + public void getsAsBoolean() { + LiteralExpression node = new LiteralExpression(true); + + node.asBooleanValue(); + assertThat(node.isBooleanValue(), is(true)); + assertThat(node.getType(), equalTo(RuntimeType.BOOLEAN)); + } + + @Test + public void getsAsBoxedBoolean() { + LiteralExpression node = new LiteralExpression(new Boolean(true)); + + node.asBooleanValue(); + assertThat(node.isBooleanValue(), is(true)); + } + + @Test + public void throwsWhenNotMap() { + LiteralExpression node = new LiteralExpression("hi"); + + Assertions.assertThrows(JmespathException.class, node::asObjectValue); + } + + @Test + public void getsAsMap() { + LiteralExpression node = new LiteralExpression(Collections.emptyMap()); + + node.asObjectValue(); + assertThat(node.isObjectValue(), is(true)); + assertThat(node.getType(), equalTo(RuntimeType.OBJECT)); + } + + @Test + public void expressionReferenceTypeIsExpref() { + assertThat(LiteralExpression.EXPREF.getType(), equalTo(RuntimeType.EXPRESSION_REFERENCE)); + } + + @Test + public void anyValueIsAnyType() { + assertThat(LiteralExpression.ANY.getType(), equalTo(RuntimeType.ANY)); + } + + @Test + public void determinesTruthyValues() { + assertThat(new LiteralExpression(0).isTruthy(), is(true)); + assertThat(new LiteralExpression(1).isTruthy(), is(true)); + assertThat(new LiteralExpression(true).isTruthy(), is(true)); + assertThat(new LiteralExpression("hi").isTruthy(), is(true)); + assertThat(new LiteralExpression(Arrays.asList(1, 2)).isTruthy(), is(true)); + assertThat(new LiteralExpression(Collections.singletonMap("a", "b")).isTruthy(), is(true)); + + assertThat(new LiteralExpression(false).isTruthy(), is(false)); + assertThat(new LiteralExpression("").isTruthy(), is(false)); + assertThat(new LiteralExpression(Collections.emptyList()).isTruthy(), is(false)); + assertThat(new LiteralExpression(Collections.emptyMap()).isTruthy(), is(false)); + } +} diff --git a/smithy-jmespath/src/test/resources/software/amazon/smithy/jmespath/invalid b/smithy-jmespath/src/test/resources/software/amazon/smithy/jmespath/invalid new file mode 100644 index 00000000000..c645594613d --- /dev/null +++ b/smithy-jmespath/src/test/resources/software/amazon/smithy/jmespath/invalid @@ -0,0 +1,101 @@ +"foo.`\"bar\"`" +"foo[8:2:0:1]", +"foo[8:2\u0026]", +"foo[2:a:3]" +"foo.1", +"foo.-11", +"foo.", +".foo", +"foo..bar", +"foo.bar.", +"foo[.]", +".", +":", +",", +"]", +"[", +"}", +"{", +")", +"(", +"((\u0026", +"a[", +"a]", +"a][", +"!", +"@=", +"@``", +"![!(!", +"(@", +"@(foo)", +".*", +"*foo", +"*0", +"foo[*]bar", +"foo[*]*", +"*.[0]", +"foo[#]", +"led[*", +"[:@]", +"[:::]", +"[:@:]", +"[:1@]", +"foo[0, 1]", +"foo.[0]", +"foo[0, ]", +"foo[0,", +"foo.[a", +"foo[0,, 1]", +"foo[abc]", +"foo[abc, def]", +"foo[abc, 1]", +"foo[abc, ]", +"foo.[abc, 1]", +"foo.[abc, ]", +"foo.[abc,, def]", +"foo.[0, 1]", +"a{}", +"a{", +"a{foo}", +"a{foo:", +"a{foo: 0", +"a{foo:}", +"a{foo: 0, ", +"a{foo: ,}", +"a{foo: bar}", +"a{foo: 0}", +"a.{}", +"a.{foo}", +"a.{foo: bar, }", +"a.{foo: bar, baz}", +"a.{foo: bar, baz:}", +"a.{foo: bar, baz: bam, }", +"{a: @", +"foo ||", +"foo.|| bar", +" || foo", +"foo || || foo", +"foo.[a ||]", +"\"foo", +"foo[ ?bar==`\"baz\"`]", +"foo[?bar==]", +"foo[?==]", +"foo[?==bar]", +"foo[?bar==baz?]", +"foo[?bar==`[\"foo`bar\"]`]", +"foo[?bar\u003c\u003ebaz]", +"foo[?bar^baz]", +"foo[bar==baz]", +"bar.`\"anything\"`", +"bar.baz.noexists.`\"literal\"`", +"foo[*].`\"literal\"`", +"foo[*].name.`\"literal\"`", +"foo[].name.`\"literal\"`", +"foo[].name.`\"literal\"`.`\"subliteral\"`", +"foo[*].name.noexist.`\"literal\"`", +"foo[].name.noexist.`\"literal\"`", +"twolen[*].`\"foo\"`", +"twolen[*].threelen[*].`\"bar\"`", +"twolen[].threelen[].`\"bar\"`", +"foo[? @ | @", +"\"\\u\"" diff --git a/smithy-jmespath/src/test/resources/software/amazon/smithy/jmespath/valid b/smithy-jmespath/src/test/resources/software/amazon/smithy/jmespath/valid new file mode 100644 index 00000000000..61f5abe5afe --- /dev/null +++ b/smithy-jmespath/src/test/resources/software/amazon/smithy/jmespath/valid @@ -0,0 +1,572 @@ +"foo", +"foo.bar", +"foo.bar.baz", +"foo\n.\nbar\n.baz", +"foo", +"foo.bar", +"foo.\"1\"", +"foo.\"1\"[0]", +"foo.\"-1\"" +"outer.foo || outer.bar", +"outer.foo||outer.bar", +"outer.bar || outer.baz", +"outer.bar||outer.baz", +"outer.bad || outer.foo", +"outer.bad||outer.foo", +"outer.foo || outer.bad", +"outer.foo||outer.bad", +"outer.empty_string || outer.foo", +"outer.nokey || outer.bool || outer.empty_list || outer.empty_string || outer.foo", +"True \u0026\u0026 True", +"True \u0026\u0026 Number", +"Number \u0026\u0026 True", +"Number \u0026\u0026 True", +"True || False", +"True || True", +"False || True", +"Number || EmptyList", +"Number || True", +"Number || True \u0026\u0026 False", +"Number || (True \u0026\u0026 False)", +"!False", +"!EmptyList", +"True \u0026\u0026 !False", +"True \u0026\u0026 !EmptyList", +"!False \u0026\u0026 !EmptyList", +"!(True \u0026\u0026 False)", +"!!Zero", +"one \u003c two", +"one \u003c= two", +"one == one", +"one != two", +"one \u003c two \u0026\u0026 three \u003e one", +"one \u003c two || three \u003e one", +"one \u003c two || three \u003c one" +"@", +"@.bar", +"@.foo[0]" +"\"foo.bar\"", +"\"foo bar\"", +"\"foo\\nbar\"", +"\"foo\\\"bar\"", +"\"c:\\\\\\\\windows\\\\path\"", +"\"/unix/path\"", +"\"\\\"\\\"\\\"\"", +"\"bar\".\"baz\"" +"foo[?name == 'a']", +"*[?[0] == `0`]", +"foo[?first == last]", +"foo[?first == last].first", +"foo[?age \u003e `25`]", +"foo[?age \u003e= `25`]", +"foo[?age \u003c `25`]", +"foo[?age \u003c= `25`]", +"foo[?age == `20`]", +"foo[?age != `20`]", +"foo[?top.name == 'a']", +"foo[?top.first == top.last]", +"foo[?top == `{\"first\": \"foo\", \"last\": \"bar\"}`]", +"foo[?key == `true`]", +"foo[?key == `false`]", +"foo[?key == `0`]", +"foo[?key == `1`]", +"foo[?key == `[0]`]", +"foo[?key == `{\"bar\": [0]}`]", +"foo[?key == `null`]", +"foo[?key == `[1]`]", +"foo[?key == `{\"a\":2}`]", +"foo[?`true` == key]", +"foo[?`false` == key]", +"foo[?`0` == key]", +"foo[?`1` == key]", +"foo[?`[0]` == key]", +"foo[?`{\"bar\": [0]}` == key]", +"foo[?`null` == key]", +"foo[?`[1]` == key]", +"foo[?`{\"a\":2}` == key]", +"foo[?key != `true`]", +"foo[?key != `false`]", +"foo[?key != `0`]", +"foo[?key != `1`]", +"foo[?key != `null`]", +"foo[?key != `[1]`]", +"foo[?key != `{\"a\":2}`]", +"foo[?`true` != key]", +"foo[?`false` != key]", +"foo[?`0` != key]", +"foo[?`1` != key]", +"foo[?`null` != key]", +"foo[?`[1]` != key]", +"foo[?`{\"a\":2}` != key]", +"reservations[].instances[?bar==`1`]", +"reservations[*].instances[?bar==`1`]", +"reservations[].instances[?bar==`1`][]", +"foo[?a==`1`].b.c", +"foo[?name == 'a' || name == 'b']", +"foo[?name == 'a' || name == 'e']", +"foo[?name == 'a' || name == 'b' || name == 'c']", +"foo[?a == `1` \u0026\u0026 b == `2`]", +"foo[?c == `3` || a == `1` \u0026\u0026 b == `4`]", +"foo[?b == `2` || a == `3` \u0026\u0026 b == `4`]", +"foo[?a == `3` \u0026\u0026 b == `4` || b == `2`]", +"foo[?(a == `3` \u0026\u0026 b == `4`) || b == `2`]", +"foo[?((a == `3` \u0026\u0026 b == `4`)) || b == `2`]", +"foo[?a == `3` \u0026\u0026 (b == `4` || b == `2`)]", +"foo[?a == `3` \u0026\u0026 ((b == `4` || b == `2`))]", +"foo[?a == `1` || b ==`2` \u0026\u0026 c == `5`]", +"foo[?!(a == `1` || b ==`2`)]", +"foo[?key]", +"foo[?!key]", +"foo[?key == `null`]", +"foo[?@ \u003c `5`]", +"foo[?`5` \u003e @]", +"foo[?@ == @]" +"abs(foo)", +"abs(foo)", +"abs(array[1])", +"abs(array[1])", +"abs(`-24`)", +"abs(`-24`)", +"avg(numbers)", +"ceil(`1.2`)", +"ceil(decimals[0])", +"ceil(decimals[1])", +"ceil(decimals[2])", +"contains('abc', 'a')", +"contains(strings, 'a')", +"contains(decimals, `1.2`)", +"ends_with(str, 'r')", +"ends_with(str, 'tr')", +"ends_with(str, 'Str')", +"floor(`1.2`)", +"floor(decimals[0])", +"floor(foo)", +"length('abc')", +"length('✓foo')", +"length('')", +"length(@)", +"length(strings[0])", +"length(str)", +"length(array)", +"length(objects)", +"length(strings[0])", +"max(numbers)", +"max(decimals)", +"max(strings)", +"max(decimals)", +"merge(`{\"a\": 1}`, `{\"b\": 2}`)", +"merge(`{\"a\": 1}`, `{\"a\": 2}`)", +"merge(`{\"a\": 1, \"b\": 2}`, `{\"a\": 2, \"c\": 3}`, `{\"d\": 4}`)", +"min(numbers)", +"min(decimals)", +"min(decimals)", +"min(strings)", +"type('abc')", +"type(`1.0`)", +"type(`2`)", +"type(`true`)", +"type(`false`)", +"type(`null`)", +"type(`[0]`)", +"type(`{\"a\": \"b\"}`)", +"type(@)", +"sort(keys(objects))", +"sort(values(objects))", +"join(', ', strings)", +"join(', ', strings)", +"join(',', `[\"a\", \"b\"]`)", +"join('|', strings)", +"join('|', decimals[].to_string(@))", +"reverse(numbers)", +"reverse(array)", +"reverse('hello world')", +"starts_with(str, 'S')", +"starts_with(str, 'St')", +"starts_with(str, 'Str')", +"sum(numbers)", +"sum(decimals)", +"sum(array[].to_number(@))", +"sum(`[]`)", +"to_array('foo')", +"to_array(`0`)", +"to_array(objects)", +"to_array(`[1, 2, 3]`)", +"to_array(false)", +"to_string('foo')", +"to_string(`1.2`)", +"to_string(`[0, 1]`)", +"to_number('1.0')", +"to_number('1.1')", +"to_number('4')", +"sort(numbers)", +"sort(strings)", +"sort(decimals)", +"not_null(unknown_key, str)", +"numbers[].to_string(@)", +"array[].to_number(@)", +"foo[].not_null(f, e, d, c, b, a)", +"sort_by(people, \u0026age)", +"sort_by(people, \u0026age_str)", +"sort_by(people, \u0026to_number(age_str))", +"sort_by(people, \u0026age)[].name", +"sort_by(people, \u0026age)[].extra", +"max_by(people, \u0026age)", +"max_by(people, \u0026age_str)", +"max_by(people, \u0026to_number(age_str))", +"min_by(people, \u0026age)", +"min_by(people, \u0026age_str)", +"min_by(people, \u0026to_number(age_str))", +"sort_by(people, \u0026age)", +"map(\u0026a, people)", +"map(\u0026c, people)", +"map(\u0026foo.bar, array)", +"map(\u0026foo1.bar, array)", +"map(\u0026foo.bar.baz, array)", +"map(\u0026[], array)" +"__L", +"\"!\\r\"", +"Y_1623", +"x", +"\"\\tF\\uCebb\"", +"\" \\t\"", +"\" \"", +"v2", +"\"\\t\"", +"_X", +"\"\\t4\\ud9da\\udd15\"", +"v24_W", +"\"H\"", +"\"\\f\"", +"\"E4\"", +"\"!\"", +"tM", +"\" [\"", +"\"R!\"", +"_6W", +"\"\\uaBA1\\r\"", +"tL7", +"\"\u003c\u003cU\\t\"", +"\"\\ubBcE\\ufAfB\"", +"sNA_", +"\"9\"", +"\"\\\\\\b\\ud8cb\\udc83\"", +"\"r\"", +"Q", +"_Q__7GL8", +"\"\\\\\"", +"RR9_", +"\"\\r\\f:\"", +"r7", +"\"-\"", +"p9", +"__", +"\"\\b\\t\"", +"O_", +"_r_8", +"_j", +"\":\"", +"\"\\rB\"", +"Obf", +"\"\\n\"", +"\"\\f󥌳\"", +"\"\\\\\\u4FDc\"", +"\"\\r\"", +"m_", +"\"\\r\\fB \"", +"\"+\\\"\\\"\"", +"Mg", +"\"\\\"!\\/\"", +"\"7\\\"\"", +"\"\\\\󞢤S\"", +"\"\\\"\"", +"Kl", +"\"\\b\\b\"", +"\"\u003e\"", +"hvu", +"\"; !\"", +"hU", +"\"!I\\n\\/\"", +"\"\\uEEbF\"", +"\"U)\\t\"", +"fa0_9", +"\"/\"", +"Gy", +"\"\\b\"", +"\"\u003c\"", +"\"\\t\"", +"\"\\t\u0026\\\\\\r\"", +"\"#\"", +"B__", +"\"\\nS \\n\"", +"Bp", +"\",\\t;\"", +"B_q", +"\"\\/+\\t\\n\\b!Z\"", +"\"󇟇\\\\ueFAc\"", +"\":\\f\"", +"\"\\/\"", +"_BW_6Hg_Gl", +"\"􃰂\"", +"zs1DC", +"__434", +"\"󵅁\"", +"Z_5", +"z_M_", +"YU_2", +"_0", +"\"\\b+\"", +"\"\\\"\"", +"D7", +"_62L", +"\"\\tK\\t\"", +"\"\\n\\\\\\f\"", +"I_", +"W_a0_", +"BQ", +"\"\\tX$\\uABBb\"", +"Z9", +"\"\\b%\\\"򞄏\"", +"_F", +"\"!,\"", +"\"\\\"!\"", +"Hh", +"\"\u0026\"", +"\"9\\r\\\\R\"", +"M_k", +"\"!\\b\\n󑩒\\\"\\\"\"", +"\"6\"", +"_7", +"\"0\"", +"\"\\\\8\\\\\"", +"b7eo", +"xIUo9", +"\"5\"", +"\"?\"", +"sU", +"\"VH2\u0026H\\\\\\/\"", +"_C", +"_", +"\"\u003c\\t\"", +"\"\\uD834\\uDD1E\"" +"foo.bar[0]", +"foo.bar[1]", +"foo.bar[2]", +"foo.bar[-1]", +"foo.bar[-2]", +"foo.bar[-3]", +"foo[0].bar", +"foo[1].bar", +"foo[2].bar", +"foo[3].notbar", +"foo[0]", +"foo[1]", +"foo[2]", +"foo[3]", +"[0]", +"[1]", +"[2]", +"[-1]", +"[-2]", +"[-3]", +"reservations[].instances[].foo", +"reservations[].instances[].foo[].bar", +"reservations[].instances[].notfoo[].bar", +"reservations[].instances[].notfoo[].notbar", +"reservations[].instances[].foo[].notbar", +"reservations[].instances[].bar[].baz", +"reservations[].instances[].baz[].baz", +"reservations[].instances[].qux[].baz", +"reservations[].instances[].qux[].baz[]", +"foo[]", +"foo[][0]", +"foo[][1]", +"foo", +"foo[]", +"foo[].bar", +"foo[].bar[]", +"foo[].bar[].baz" +"`\"foo\"`", +"`\"\\u03a6\"`", +"`\"✓\"`", +"`[1, 2, 3]`", +"`{\"a\": \"b\"}`", +"`true`", +"`0`", +"`1`", +"`2`", +"`3`", +"`4`", +"`5`", +"`6`", +"`7`", +"`8`", +"`9`", +"`\"foo\\`bar\"`", +"`\"foo\\\"bar\"`", +"`\"1\\`\"`", +"`\"\\\\\"`.{a:`\"b\"`}", +"`{\"a\": \"b\"}`.a", +"`{\"a\": {\"b\": \"c\"}}`.a.b", +"`[0, 1, 2]`[1]", +"` {\"foo\": true}`", +"`{\"foo\": true} `", +"'foo'", +"' foo '", +"'0'", +"'newline\n'", +"'\n'", +"'✓'", +"'𝄞'", +"' [foo] '", +"'[foo]'", +"'\\u03a6'", +"'foo\\'bar'", +"'\\z'", +"'\\\\'" +"foo.{bar: bar}", +"foo.{\"bar\": bar}", +"foo.{\"foo.bar\": bar}", +"foo.{bar: bar, baz: baz}", +"foo.{\"bar\": bar, \"baz\": baz}", +"{\"baz\": baz, \"qux\\\"\": \"qux\\\"\"}", +"foo.{bar:bar,baz:baz}", +"foo.{bar: bar,qux: qux}", +"foo.{bar: bar, noexist: noexist}", +"foo.{noexist: noexist, alsonoexist: alsonoexist}", +"foo.nested.*.{a: a,b: b}", +"foo.nested.three.{a: a, cinner: c.inner}", +"foo.nested.three.{a: a, c: c.inner.bad.key}", +"foo.{a: nested.one.a, b: nested.two.b}", +"{bar: bar, baz: baz}", +"{bar: bar}", +"{otherkey: bar}", +"{no: no, exist: exist}", +"foo.[bar]", +"foo.[bar,baz]", +"foo.[bar,qux]", +"foo.[bar,noexist]", +"foo.[noexist,alsonoexist]", +"foo.{bar:bar,baz:baz}", +"foo.[bar,baz[0]]", +"foo.[bar,baz[1]]", +"foo.[bar,baz[2]]", +"foo.[bar,baz[3]]", +"foo.[bar[0],baz[3]]", +"foo.{bar: bar, baz: baz}", +"foo.[bar,baz]", +"foo.{bar: bar.baz[1],includeme: includeme}", +"foo.{\"bar.baz.two\": bar.baz[1].two, includeme: includeme}", +"foo.[includeme, bar.baz[*].common]", +"foo.[includeme, bar.baz[*].none]", +"foo.[includeme, bar.baz[].common]", +"reservations[*].instances[*].{id: id, name: name}", +"reservations[].instances[].{id: id, name: name}", +"reservations[].instances[].[id, name]", +"foo", +"foo[]", +"foo[].bar", +"foo[].bar[]", +"foo[].bar[].[baz, qux]", +"foo[].bar[].[baz]", +"foo[].bar[].[baz, qux][]", +"foo.[baz[*].bar, qux[0]]", +"foo.[baz[*].[bar, boo], qux[0]]", +"foo.[baz[*].not_there || baz[*].bar, qux[0]]", +"[[*],*]", +"[[*]]" +"foo.*.baz | [0]", +"foo.*.baz | [1]", +"foo.*.baz | [2]", +"foo.bar.* | [0]", +"foo.*.notbaz | [*]", +"{\"a\": foo.bar, \"b\": foo.other} | *.baz", +"foo | bar", +"foo | bar | baz", +"foo|bar| baz", +"[foo.bar, foo.other] | [0]", +"{\"a\": foo.bar, \"b\": foo.other} | a", +"{\"a\": foo.bar, \"b\": foo.other} | b", +"foo.bam || foo.bar | baz", +"foo | not_there || bar", +"foo[*].bar[*] | [0][0]" +"foo[0:10:1]", +"foo[0:10]", +"foo[0:10:]", +"foo[0::1]", +"foo[0::]", +"foo[0:]", +"foo[:10:1]", +"foo[::1]", +"foo[:10:]", +"foo[::]", +"foo[:]", +"foo[1:9]", +"foo[0:10:2]", +"foo[5:]", +"foo[5::2]", +"foo[::2]", +"foo[::-1]", +"foo[1::2]", +"foo[10:0:-1]", +"foo[10:5:-1]", +"foo[8:2:-2]", +"foo[0:20]", +"foo[10:-20:-1]", +"foo[-4:-1]", +"foo[:-5:-1]", +"foo[:2].a", +"bar[::-1].a.b", +"bar[:2].a.b", +"[:]", +"[:2].a", +"[::-1].a" +"*", +"*.[\"0\"]", +"{\"\\\\\":{\" \":*}}", +"[*.*]" +"foo[].\"✓\"", +"\"☯\"", +"\"♪♫•*¨*•.¸¸❤¸¸.•*¨*•♫♪\"", +"\"☃\"" +"foo.*.baz", +"foo.bar.*", +"foo.*.notbaz", +"foo.*.notbaz[0]", +"foo.*.notbaz[-1]", +"foo.*", +"foo.*.*", +"foo.*.*.*", +"foo.*.*.*.*", +"*.bar", +"*", +"*.sub1", +"*.*", +"*.*.foo[]", +"*.sub1.foo", +"foo[*].bar", +"foo[*].notbar", +"[*]", +"[*].bar", +"[*].notbar", +"foo.bar[*].baz", +"foo.bar[*].baz[0]", +"foo.bar[*].baz[1]", +"foo.bar[*].baz[2]", +"foo.bar[*]", +"foo.bar[0]", +"foo.bar[0][0]", +"foo[*].bar[*].kind", +"foo[*].bar[0].kind", +"foo[*].bar.kind", +"foo[*].bar[0]", +"foo[*].bar[1]", +"foo[*][0]", +"foo[*][1]", +"foo[*][0]", +"foo[*][1]", +"foo[*][0][0]", +"foo[*][1][0]", +"foo[*][0][1]", +"foo[*][1][1]", +"hash.*", +"*[0]" From a8974c5eb9344341cab2b251085e64b8a53f9219 Mon Sep 17 00:00:00 2001 From: Michael Dowling Date: Mon, 2 Nov 2020 23:53:16 -0800 Subject: [PATCH 2/5] Address PR feedback --- smithy-jmespath/README.md | 2 +- .../smithy/jmespath/ExpressionVisitor.java | 8 +- .../smithy/jmespath/FunctionDefinition.java | 6 +- .../amazon/smithy/jmespath/Lexer.java | 6 +- .../amazon/smithy/jmespath/LinterResult.java | 17 +- .../amazon/smithy/jmespath/Parser.java | 22 +-- .../amazon/smithy/jmespath/RuntimeType.java | 145 +++++++++++++++++- .../amazon/smithy/jmespath/TypeChecker.java | 116 +++----------- .../smithy/jmespath/ast/AndExpression.java | 2 + ...ression.java => ComparatorExpression.java} | 14 +- .../smithy/jmespath/ast/ComparatorType.java | 2 +- .../jmespath/ast/CurrentExpression.java | 2 + ...ion.java => ExpressionTypeExpression.java} | 14 +- .../smithy/jmespath/ast/FieldExpression.java | 5 + .../ast/FilterProjectionExpression.java | 10 ++ .../jmespath/ast/FlattenExpression.java | 2 + .../jmespath/ast/FunctionExpression.java | 2 + .../smithy/jmespath/ast/IndexExpression.java | 9 +- .../jmespath/ast/LiteralExpression.java | 28 ++-- .../ast/MultiSelectHashExpression.java | 6 +- .../ast/MultiSelectListExpression.java | 2 + .../smithy/jmespath/ast/NotExpression.java | 4 +- .../ast/ObjectProjectionExpression.java | 7 + .../smithy/jmespath/ast/OrExpression.java | 2 + .../jmespath/ast/ProjectionExpression.java | 5 + .../smithy/jmespath/ast/SliceExpression.java | 2 + .../smithy/jmespath/ast/Subexpression.java | 6 + .../amazon/smithy/jmespath/LexerTest.java | 46 +++--- .../amazon/smithy/jmespath/ParserTest.java | 14 +- .../amazon/smithy/jmespath/RunnerTest.java | 4 +- .../smithy/jmespath/TypeCheckerTest.java | 2 +- .../jmespath/ast/LiteralExpressionTest.java | 24 +-- 32 files changed, 332 insertions(+), 204 deletions(-) rename smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/{ComparisonExpression.java => ComparatorExpression.java} (82%) rename smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/{ExpressionReferenceExpression.java => ExpressionTypeExpression.java} (77%) diff --git a/smithy-jmespath/README.md b/smithy-jmespath/README.md index 56ba055c736..0cfccbfb567 100644 --- a/smithy-jmespath/README.md +++ b/smithy-jmespath/README.md @@ -2,6 +2,6 @@ This is an implementation of a [JMESPath](https://jmespath.org/) parser written in Java. It's not intended to be used at runtime and does not include -an interpreter. It doesn't implement functions. Its goal is to parser +an interpreter. It doesn't implement functions. Its goal is to parse JMESPath expressions, perform static analysis on them, and provide an AST that can be used for code generation. diff --git a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ExpressionVisitor.java b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ExpressionVisitor.java index 2b7cff93a63..73ce90cef9e 100644 --- a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ExpressionVisitor.java +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ExpressionVisitor.java @@ -16,9 +16,9 @@ package software.amazon.smithy.jmespath; import software.amazon.smithy.jmespath.ast.AndExpression; -import software.amazon.smithy.jmespath.ast.ComparisonExpression; +import software.amazon.smithy.jmespath.ast.ComparatorExpression; import software.amazon.smithy.jmespath.ast.CurrentExpression; -import software.amazon.smithy.jmespath.ast.ExpressionReferenceExpression; +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; @@ -41,11 +41,11 @@ */ public interface ExpressionVisitor { - T visitComparison(ComparisonExpression expression); + T visitComparator(ComparatorExpression expression); T visitCurrentNode(CurrentExpression expression); - T visitExpressionReference(ExpressionReferenceExpression expression); + T visitExpressionType(ExpressionTypeExpression expression); T visitFlatten(FlattenExpression expression); diff --git a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/FunctionDefinition.java b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/FunctionDefinition.java index c8349eabd31..4416af822dd 100644 --- a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/FunctionDefinition.java +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/FunctionDefinition.java @@ -19,6 +19,10 @@ import java.util.List; import software.amazon.smithy.jmespath.ast.LiteralExpression; +/** + * Defines the positional arguments, variadic arguments, and return value + * of JMESPath functions. + */ final class FunctionDefinition { @FunctionalInterface @@ -57,7 +61,7 @@ static ArgValidator listOfType(RuntimeType type) { if (type == RuntimeType.ANY || arg.getType() == RuntimeType.ANY) { return null; } else if (arg.getType() == RuntimeType.ARRAY) { - List values = arg.asArrayValue(); + List values = arg.expectArrayValue(); for (int i = 0; i < values.size(); i++) { LiteralExpression element = LiteralExpression.from(values.get(i)); if (element.getType() != type) { diff --git a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/Lexer.java b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/Lexer.java index 244ba9b039f..712b774ed3e 100644 --- a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/Lexer.java +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/Lexer.java @@ -537,7 +537,7 @@ private Object parseJsonValue() { // Backtrack for positioning. position--; column--; - return parseString().value.asStringValue(); + return parseString().value.expectStringValue(); case '{': return parseJsonObject(); case '[': @@ -546,7 +546,7 @@ private Object parseJsonValue() { // Backtrack. position--; column--; - return parseNumber().value.asNumberValue(); + return parseNumber().value.expectNumberValue(); } } @@ -587,7 +587,7 @@ private Object parseJsonObject() { } while (!eof() && peek() != '`') { - String key = parseString().value.asStringValue(); + String key = parseString().value.expectStringValue(); ws(); expect(':'); ws(); diff --git a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/LinterResult.java b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/LinterResult.java index 6a9acdbc2b4..a068e151bbc 100644 --- a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/LinterResult.java +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/LinterResult.java @@ -18,20 +18,33 @@ import java.util.Objects; import java.util.Set; +/** + * Contains the result of {@link JmespathExpression#lint}. + */ public final class LinterResult { - public final RuntimeType returnType; - public final Set problems; + private final RuntimeType returnType; + private final Set problems; public LinterResult(RuntimeType returnType, Set problems) { this.returnType = returnType; this.problems = problems; } + /** + * Gets the statically known return type of the expression. + * + * @return Returns the return type of the expression. + */ public RuntimeType getReturnType() { return returnType; } + /** + * Gets the set of problems in the expression. + * + * @return Returns the detected problems. + */ public Set getProblems() { return problems; } diff --git a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/Parser.java b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/Parser.java index 7ef421d4d8f..f46809bb5ff 100644 --- a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/Parser.java +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/Parser.java @@ -20,10 +20,10 @@ import java.util.List; import java.util.Map; import software.amazon.smithy.jmespath.ast.AndExpression; +import software.amazon.smithy.jmespath.ast.ComparatorExpression; import software.amazon.smithy.jmespath.ast.ComparatorType; -import software.amazon.smithy.jmespath.ast.ComparisonExpression; import software.amazon.smithy.jmespath.ast.CurrentExpression; -import software.amazon.smithy.jmespath.ast.ExpressionReferenceExpression; +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; @@ -69,7 +69,6 @@ final class Parser { TokenType.OR, TokenType.AND, TokenType.PIPE, - TokenType.LPAREN, TokenType.FLATTEN, TokenType.FILTER, TokenType.EQUAL, @@ -77,7 +76,10 @@ final class Parser { TokenType.GREATER_THAN, TokenType.GREATER_THAN_EQUAL, TokenType.LESS_THAN, - TokenType.LESS_THAN_EQUAL + TokenType.LESS_THAN_EQUAL, + // While not found in the led() method, a led LPAREN is handled + // when parsing a nud identifier because it creates a function. + TokenType.LPAREN }; private final String expression; @@ -113,9 +115,9 @@ private JmespathExpression nud() { if (iterator.peek().type == TokenType.LPAREN) { iterator.expect(TokenType.LPAREN); List arguments = parseList(TokenType.RPAREN); - return new FunctionExpression(token.value.asStringValue(), arguments, token.line, token.column); + return new FunctionExpression(token.value.expectStringValue(), arguments, token.line, token.column); } else { - return new FieldExpression(token.value.asStringValue(), token.line, token.column); + return new FieldExpression(token.value.expectStringValue(), token.line, token.column); } case STAR: // Example: * return parseWildcardObject(new CurrentExpression(token.line, token.column)); @@ -129,7 +131,7 @@ private JmespathExpression nud() { return parseFlatten(new CurrentExpression(token.line, token.column)); case EXPREF: // Example: sort_by(@, &foo) JmespathExpression expressionRef = expression(token.type.lbp); - return new ExpressionReferenceExpression(expressionRef, token.line, token.column); + return new ExpressionTypeExpression(expressionRef, token.line, token.column); case NOT: // Example: !foo JmespathExpression notNode = expression(token.type.lbp); return new NotExpression(notNode, token.line, token.column); @@ -227,7 +229,7 @@ private JmespathExpression parseIndex() { switch (next.type) { case NUMBER: iterator.expect(TokenType.NUMBER); - parts[pos] = next.value.asNumberValue().intValue(); + parts[pos] = next.value.expectNumberValue().intValue(); iterator.expectPeek(TokenType.COLON, TokenType.RBRACKET); break; case RBRACKET: @@ -301,7 +303,7 @@ private JmespathExpression parseNudLbrace() { Token key = iterator.expect(TokenType.IDENTIFIER); iterator.expect(TokenType.COLON); JmespathExpression value = expression(0); - entries.put(key.value.asStringValue(), value); + entries.put(key.value.expectStringValue(), value); if (iterator.expectPeek(TokenType.RBRACE, TokenType.COMMA).type == TokenType.COMMA) { iterator.expect(TokenType.COMMA); @@ -363,7 +365,7 @@ private JmespathExpression parseComparator(ComparatorType comparatorType, Jmespa int line = iterator.line(); int column = iterator.column(); JmespathExpression rhs = expression(TokenType.EQUAL.lbp); - return new ComparisonExpression(comparatorType, lhs, rhs, line, column); + return new ComparatorExpression(comparatorType, lhs, rhs, line, column); } // Parses the right hand side of a ".". diff --git a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/RuntimeType.java b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/RuntimeType.java index 1063a1ce7c2..ec42e58f2fe 100644 --- a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/RuntimeType.java +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/RuntimeType.java @@ -16,19 +16,148 @@ package software.amazon.smithy.jmespath; import java.util.Locale; +import software.amazon.smithy.jmespath.ast.ComparatorType; +import software.amazon.smithy.jmespath.ast.LiteralExpression; public enum RuntimeType { - STRING, - NUMBER, - BOOLEAN, - NULL, - ARRAY, - OBJECT, - EXPRESSION_REFERENCE, - ANY; + + STRING { + @Override + public LiteralExpression compare(LiteralExpression left, LiteralExpression right, ComparatorType comparator) { + if (left.getType() != right.getType()) { + return LiteralExpression.BOOLEAN; + } + switch (comparator) { + case EQUAL: + return new LiteralExpression(left.expectStringValue().equals(right.expectStringValue())); + case NOT_EQUAL: + return new LiteralExpression(!left.expectStringValue().equals(right.expectStringValue())); + default: + return LiteralExpression.NULL; + } + } + }, + + NUMBER { + @Override + public LiteralExpression compare(LiteralExpression left, LiteralExpression right, ComparatorType comparator) { + if (left.getType() != right.getType()) { + return LiteralExpression.BOOLEAN; + } + double comparison = left.expectNumberValue().doubleValue() - right.expectNumberValue().doubleValue(); + switch (comparator) { + case EQUAL: + return new LiteralExpression(comparison == 0); + case NOT_EQUAL: + return new LiteralExpression(comparison != 0); + case GREATER_THAN: + return new LiteralExpression(comparison > 0); + case GREATER_THAN_EQUAL: + return new LiteralExpression(comparison >= 0); + case LESS_THAN: + return new LiteralExpression(comparison < 0); + case LESS_THAN_EQUAL: + return new LiteralExpression(comparison <= 0); + default: + throw new IllegalArgumentException("Unreachable comparator " + comparator); + } + } + }, + + BOOLEAN { + @Override + public LiteralExpression compare(LiteralExpression left, LiteralExpression right, ComparatorType comparator) { + if (left.getType() != right.getType()) { + return LiteralExpression.BOOLEAN; + } + switch (comparator) { + case EQUAL: + return new LiteralExpression(left.expectBooleanValue() == right.expectBooleanValue()); + case NOT_EQUAL: + return new LiteralExpression(left.expectBooleanValue() != right.expectBooleanValue()); + default: + return LiteralExpression.NULL; + } + } + }, + + NULL { + @Override + public LiteralExpression compare(LiteralExpression left, LiteralExpression right, ComparatorType comparator) { + if (left.getType() != right.getType()) { + return LiteralExpression.BOOLEAN; + } + switch (comparator) { + case EQUAL: + return new LiteralExpression(true); + case NOT_EQUAL: + return new LiteralExpression(false); + default: + return LiteralExpression.NULL; + } + } + }, + + ARRAY { + @Override + public LiteralExpression compare(LiteralExpression left, LiteralExpression right, ComparatorType comparator) { + if (left.getType() != right.getType()) { + return LiteralExpression.BOOLEAN; + } + switch (comparator) { + case EQUAL: + return new LiteralExpression(left.expectArrayValue().equals(right.expectArrayValue())); + case NOT_EQUAL: + return new LiteralExpression(!left.expectArrayValue().equals(right.expectArrayValue())); + default: + return LiteralExpression.NULL; + } + } + }, + + OBJECT { + @Override + public LiteralExpression compare(LiteralExpression left, LiteralExpression right, ComparatorType comparator) { + if (left.getType() != right.getType()) { + return LiteralExpression.BOOLEAN; + } + switch (comparator) { + case EQUAL: + return new LiteralExpression(left.expectObjectValue().equals(right.expectObjectValue())); + case NOT_EQUAL: + return new LiteralExpression(!left.expectObjectValue().equals(right.expectObjectValue())); + default: + return LiteralExpression.NULL; + } + } + }, + + EXPRESSION { + @Override + public LiteralExpression compare(LiteralExpression left, LiteralExpression right, ComparatorType comparator) { + if (left.getType() != right.getType()) { + return LiteralExpression.BOOLEAN; + } else { + return LiteralExpression.NULL; + } + } + }, + + ANY { + @Override + public LiteralExpression compare(LiteralExpression left, LiteralExpression right, ComparatorType comparator) { + // Just assume any kind of ANY comparison is satisfied. + return new LiteralExpression(true); + } + }; @Override public String toString() { return super.toString().toLowerCase(Locale.ENGLISH); } + + public abstract LiteralExpression compare( + LiteralExpression left, + LiteralExpression right, + ComparatorType comparator); } diff --git a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/TypeChecker.java b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/TypeChecker.java index da1d7d8e3dd..1d165cbacca 100644 --- a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/TypeChecker.java +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/TypeChecker.java @@ -35,10 +35,10 @@ import java.util.Map; import java.util.Set; import software.amazon.smithy.jmespath.ast.AndExpression; +import software.amazon.smithy.jmespath.ast.ComparatorExpression; import software.amazon.smithy.jmespath.ast.ComparatorType; -import software.amazon.smithy.jmespath.ast.ComparisonExpression; import software.amazon.smithy.jmespath.ast.CurrentExpression; -import software.amazon.smithy.jmespath.ast.ExpressionReferenceExpression; +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; @@ -76,17 +76,17 @@ final class TypeChecker implements ExpressionVisitor { FUNCTIONS.put("length", new FunctionDefinition( NUMBER, oneOf(RuntimeType.STRING, RuntimeType.ARRAY, RuntimeType.OBJECT))); // TODO: Support expression reference return type validation? - FUNCTIONS.put("map", new FunctionDefinition(ARRAY, isType(RuntimeType.EXPRESSION_REFERENCE), isArray)); + FUNCTIONS.put("map", new FunctionDefinition(ARRAY, isType(RuntimeType.EXPRESSION), isArray)); // TODO: support array FUNCTIONS.put("max", new FunctionDefinition(NUMBER, isArray)); - FUNCTIONS.put("max_by", new FunctionDefinition(NUMBER, isArray, isType(RuntimeType.EXPRESSION_REFERENCE))); + FUNCTIONS.put("max_by", new FunctionDefinition(NUMBER, isArray, isType(RuntimeType.EXPRESSION))); FUNCTIONS.put("merge", new FunctionDefinition(OBJECT, Collections.emptyList(), isType(RuntimeType.OBJECT))); FUNCTIONS.put("min", new FunctionDefinition(NUMBER, isArray)); - FUNCTIONS.put("min_by", new FunctionDefinition(NUMBER, isArray, isType(RuntimeType.EXPRESSION_REFERENCE))); + FUNCTIONS.put("min_by", new FunctionDefinition(NUMBER, isArray, isType(RuntimeType.EXPRESSION))); FUNCTIONS.put("not_null", new FunctionDefinition(ANY, Collections.singletonList(isAny), isAny)); FUNCTIONS.put("reverse", new FunctionDefinition(ARRAY, oneOf(RuntimeType.ARRAY, RuntimeType.STRING))); FUNCTIONS.put("sort", new FunctionDefinition(ARRAY, isArray)); - FUNCTIONS.put("sort_by", new FunctionDefinition(ARRAY, isArray, isType(RuntimeType.EXPRESSION_REFERENCE))); + FUNCTIONS.put("sort_by", new FunctionDefinition(ARRAY, isArray, isType(RuntimeType.EXPRESSION))); FUNCTIONS.put("starts_with", new FunctionDefinition(BOOLEAN, isString, isString)); FUNCTIONS.put("sum", new FunctionDefinition(NUMBER, listOfType(RuntimeType.NUMBER))); FUNCTIONS.put("to_array", new FunctionDefinition(ARRAY, isAny)); @@ -106,92 +106,16 @@ final class TypeChecker implements ExpressionVisitor { } @Override - public LiteralExpression visitComparison(ComparisonExpression expression) { + public LiteralExpression visitComparator(ComparatorExpression expression) { LiteralExpression left = expression.getLeft().accept(this); LiteralExpression right = expression.getRight().accept(this); + LiteralExpression result = left.getType().compare(left, right, expression.getComparator()); - // Different types always cause a comparison to not match. - if (left.getType() != right.getType()) { - return BOOLEAN; + if (result.getType() == RuntimeType.NULL) { + badComparator(expression, left.getType(), expression.getComparator()); } - // I'm so sorry for the following code. - switch (left.getType()) { - case STRING: - switch (expression.getComparator()) { - case EQUAL: - return new LiteralExpression(left.asStringValue().equals(right.asStringValue())); - case NOT_EQUAL: - return new LiteralExpression(!left.asStringValue().equals(right.asStringValue())); - default: - badComparator(expression, left.getType(), expression.getComparator()); - return NULL; - } - case NUMBER: - double comparison = left.asNumberValue().doubleValue() - right.asNumberValue().doubleValue(); - switch (expression.getComparator()) { - case EQUAL: - return new LiteralExpression(comparison == 0); - case NOT_EQUAL: - return new LiteralExpression(comparison != 0); - case GREATER_THAN: - return new LiteralExpression(comparison > 0); - case GREATER_THAN_EQUAL: - return new LiteralExpression(comparison >= 0); - case LESS_THAN: - return new LiteralExpression(comparison < 0); - case LESS_THAN_EQUAL: - return new LiteralExpression(comparison <= 0); - default: - throw new IllegalArgumentException("Unreachable comparator " + expression.getComparator()); - } - case BOOLEAN: - switch (expression.getComparator()) { - case EQUAL: - return new LiteralExpression(left.asBooleanValue() == right.asBooleanValue()); - case NOT_EQUAL: - return new LiteralExpression(left.asBooleanValue() != right.asBooleanValue()); - default: - badComparator(expression, left.getType(), expression.getComparator()); - return NULL; - } - case NULL: - switch (expression.getComparator()) { - case EQUAL: - return new LiteralExpression(true); - case NOT_EQUAL: - return new LiteralExpression(false); - default: - badComparator(expression, left.getType(), expression.getComparator()); - return NULL; - } - case ARRAY: - switch (expression.getComparator()) { - case EQUAL: - return new LiteralExpression(left.asArrayValue().equals(right.asArrayValue())); - case NOT_EQUAL: - return new LiteralExpression(!left.asArrayValue().equals(right.asArrayValue())); - default: - badComparator(expression, left.getType(), expression.getComparator()); - return NULL; - } - case EXPRESSION_REFERENCE: - badComparator(expression, left.getType(), expression.getComparator()); - return NULL; - case OBJECT: - switch (expression.getComparator()) { - case EQUAL: - return new LiteralExpression(left.asObjectValue().equals(right.asObjectValue())); - case NOT_EQUAL: - return new LiteralExpression(!left.asObjectValue().equals(right.asObjectValue())); - default: - badComparator(expression, left.getType(), expression.getComparator()); - return NULL; - } - default: // ANY - // Just assume any kind of ANY comparison is satisfied. - return new LiteralExpression(true); - } + return result; } @Override @@ -200,7 +124,7 @@ public LiteralExpression visitCurrentNode(CurrentExpression expression) { } @Override - public LiteralExpression visitExpressionReference(ExpressionReferenceExpression expression) { + public LiteralExpression visitExpressionType(ExpressionTypeExpression expression) { // Expression references are late bound, so the type is only known // when the reference is used in a function. expression.getExpression().accept(new TypeChecker(knownFunctionType, problems)); @@ -220,10 +144,10 @@ public LiteralExpression visitFlatten(FlattenExpression expression) { // Perform the actual flattening. List flattened = new ArrayList<>(); - for (Object value : result.asArrayValue()) { + for (Object value : result.expectArrayValue()) { LiteralExpression element = LiteralExpression.from(value); if (element.isArrayValue()) { - flattened.addAll(element.asArrayValue()); + flattened.addAll(element.expectArrayValue()); } else if (!element.isNullValue()) { flattened.add(element); } @@ -240,7 +164,7 @@ public LiteralExpression visitField(FieldExpression expression) { } else { danger(expression, String.format( "Object field '%s' does not exist in object with properties %s", - expression.getName(), current.asObjectValue().keySet())); + expression.getName(), current.expectObjectValue().keySet())); return NULL; } } @@ -325,7 +249,7 @@ public LiteralExpression visitProjection(ProjectionExpression expression) { LiteralExpression leftResult = expression.getLeft().accept(this); // If LHS is not an array, then just do basic checks on RHS using ANY + ARRAY. - if (!leftResult.isArrayValue() || leftResult.asArrayValue().isEmpty()) { + if (!leftResult.isArrayValue() || leftResult.expectArrayValue().isEmpty()) { if (leftResult.getType() != RuntimeType.ANY && !leftResult.isArrayValue()) { danger(expression, "Array projection performed on " + leftResult.getType()); } @@ -335,7 +259,7 @@ public LiteralExpression visitProjection(ProjectionExpression expression) { } else { // LHS is an array, so do the projection. List result = new ArrayList<>(); - for (Object value : leftResult.asArrayValue()) { + for (Object value : leftResult.expectArrayValue()) { TypeChecker checker = new TypeChecker(LiteralExpression.from(value), problems); result.add(expression.getRight().accept(checker).getValue()); } @@ -359,7 +283,7 @@ public LiteralExpression visitObjectProjection(ObjectProjectionExpression expres // LHS is an object, so do the projection. List result = new ArrayList<>(); - for (Object value : leftResult.asObjectValue().values()) { + for (Object value : leftResult.expectObjectValue().values()) { TypeChecker checker = new TypeChecker(LiteralExpression.from(value), problems); result.add(expression.getRight().accept(checker).getValue()); } @@ -372,7 +296,7 @@ public LiteralExpression visitFilterProjection(FilterProjectionExpression expres LiteralExpression leftResult = expression.getLeft().accept(this); // If LHS is not an array or is empty, then just do basic checks on RHS using ANY + ARRAY. - if (!leftResult.isArrayValue() || leftResult.asArrayValue().isEmpty()) { + if (!leftResult.isArrayValue() || leftResult.expectArrayValue().isEmpty()) { if (!leftResult.isArrayValue() && leftResult.getType() != RuntimeType.ANY) { danger(expression, "Filter projection performed on " + leftResult.getType()); } @@ -385,7 +309,7 @@ public LiteralExpression visitFilterProjection(FilterProjectionExpression expres // It's a non-empty array, perform the actual filter. List result = new ArrayList<>(); - for (Object value : leftResult.asArrayValue()) { + for (Object value : leftResult.expectArrayValue()) { LiteralExpression literalValue = LiteralExpression.from(value); TypeChecker rightVisitor = new TypeChecker(literalValue, problems); LiteralExpression comparisonValue = expression.getComparison().accept(rightVisitor); diff --git a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/AndExpression.java b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/AndExpression.java index 08712d47cdf..3cbab9e9f38 100644 --- a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/AndExpression.java +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/AndExpression.java @@ -21,6 +21,8 @@ /** * And expression where both sides must return truthy values. The second * truthy value becomes the result of the expression. + * + * @see And Expressions */ public final class AndExpression extends BinaryExpression { diff --git a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/ComparisonExpression.java b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/ComparatorExpression.java similarity index 82% rename from smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/ComparisonExpression.java rename to smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/ComparatorExpression.java index c6ab55d38b6..29896a2de27 100644 --- a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/ComparisonExpression.java +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/ComparatorExpression.java @@ -22,16 +22,18 @@ /** * Compares the left and right expression using a comparator, * resulting in a boolean value. + * + * @see Comparator expression as defined in Filter Expressions */ -public final class ComparisonExpression extends BinaryExpression { +public final class ComparatorExpression extends BinaryExpression { private final ComparatorType comparator; - public ComparisonExpression(ComparatorType comparator, JmespathExpression left, JmespathExpression right) { + public ComparatorExpression(ComparatorType comparator, JmespathExpression left, JmespathExpression right) { this(comparator, left, right, 1, 1); } - public ComparisonExpression( + public ComparatorExpression( ComparatorType comparator, JmespathExpression left, JmespathExpression right, @@ -44,7 +46,7 @@ public ComparisonExpression( @Override public T accept(ExpressionVisitor visitor) { - return visitor.visitComparison(this); + return visitor.visitComparator(this); } /** @@ -60,10 +62,10 @@ public ComparatorType getComparator() { public boolean equals(Object o) { if (this == o) { return true; - } else if (!(o instanceof ComparisonExpression)) { + } else if (!(o instanceof ComparatorExpression)) { return false; } - ComparisonExpression that = (ComparisonExpression) o; + ComparatorExpression that = (ComparatorExpression) o; return getLeft().equals(that.getLeft()) && getRight().equals(that.getRight()) && getComparator().equals(that.getComparator()); diff --git a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/ComparatorType.java b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/ComparatorType.java index d527b0fc728..9ce4d4b6fde 100644 --- a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/ComparatorType.java +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/ComparatorType.java @@ -18,7 +18,7 @@ /** * A comparator in a comparison expression. */ -public enum ComparatorType { +public enum ComparatorType { EQUAL("=="), NOT_EQUAL("!="), diff --git a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/CurrentExpression.java b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/CurrentExpression.java index 7d8b5d33274..f2a2dd4c6a9 100644 --- a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/CurrentExpression.java +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/CurrentExpression.java @@ -20,6 +20,8 @@ /** * Gets the current node. + * + * current-node */ public final class CurrentExpression extends JmespathExpression { diff --git a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/ExpressionReferenceExpression.java b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/ExpressionTypeExpression.java similarity index 77% rename from smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/ExpressionReferenceExpression.java rename to smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/ExpressionTypeExpression.java index c1a78c04707..d8225390367 100644 --- a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/ExpressionReferenceExpression.java +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/ExpressionTypeExpression.java @@ -22,23 +22,25 @@ /** * Contains a reference to an expression that can be run zero or more * times by a function. + * + * @see Data types */ -public final class ExpressionReferenceExpression extends JmespathExpression { +public final class ExpressionTypeExpression extends JmespathExpression { private final JmespathExpression expression; - public ExpressionReferenceExpression(JmespathExpression expression) { + public ExpressionTypeExpression(JmespathExpression expression) { this(expression, 1, 1); } - public ExpressionReferenceExpression(JmespathExpression expression, int line, int column) { + public ExpressionTypeExpression(JmespathExpression expression, int line, int column) { super(line, column); this.expression = expression; } @Override public T accept(ExpressionVisitor visitor) { - return visitor.visitExpressionReference(this); + return visitor.visitExpressionType(this); } /** @@ -54,10 +56,10 @@ public JmespathExpression getExpression() { public boolean equals(Object o) { if (this == o) { return true; - } else if (!(o instanceof ExpressionReferenceExpression)) { + } else if (!(o instanceof ExpressionTypeExpression)) { return false; } - ExpressionReferenceExpression that = (ExpressionReferenceExpression) o; + ExpressionTypeExpression that = (ExpressionTypeExpression) o; return expression.equals(that.expression); } diff --git a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/FieldExpression.java b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/FieldExpression.java index cda999063a1..b65290a7739 100644 --- a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/FieldExpression.java +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/FieldExpression.java @@ -21,6 +21,11 @@ /** * Gets a field by name from an object. + * + *

This AST node is created for identifiers. For example, + * {@code foo} creates a {@code FieldExpression}. + * + * @see Identifiers */ public final class FieldExpression extends JmespathExpression { diff --git a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/FilterProjectionExpression.java b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/FilterProjectionExpression.java index ab83aaeb9d2..98f707e7cca 100644 --- a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/FilterProjectionExpression.java +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/FilterProjectionExpression.java @@ -19,6 +19,16 @@ import software.amazon.smithy.jmespath.ExpressionVisitor; import software.amazon.smithy.jmespath.JmespathExpression; +/** + * A projection that filters values using a comparison. + * + *

A filter projection executes the left AST expression, expects it to + * return an array of values, passes each result of the left expression to + * a {@link ComparatorExpression}, and yields any value from the comparison + * expression that returns {@code true} to the right AST expression. + * + * @see Filter Expressions + */ public final class FilterProjectionExpression extends JmespathExpression { private final JmespathExpression comparison; diff --git a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/FlattenExpression.java b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/FlattenExpression.java index 6321e69caea..b13d828dda0 100644 --- a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/FlattenExpression.java +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/FlattenExpression.java @@ -21,6 +21,8 @@ /** * Flattens the wrapped expression into an array. + * + * @see Flatten Operator */ public final class FlattenExpression extends JmespathExpression { diff --git a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/FunctionExpression.java b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/FunctionExpression.java index 1c893d5d052..d0bf7ab8cfc 100644 --- a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/FunctionExpression.java +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/FunctionExpression.java @@ -22,6 +22,8 @@ /** * Executes a function by name using a list of argument expressions. + * + * @see Function Expressions */ public final class FunctionExpression extends JmespathExpression { diff --git a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/IndexExpression.java b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/IndexExpression.java index 552a30129f8..d441c8630ed 100644 --- a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/IndexExpression.java +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/IndexExpression.java @@ -20,8 +20,13 @@ import software.amazon.smithy.jmespath.JmespathExpression; /** - * Gets a specific element by zero-based index. Use -1 to get the - * last element in an array. + * Gets a specific element by zero-based index. + * + *

Use a negative index to get an element from the end of the array + * (e.g., -1 is used to get the last element of the array). If an + * array element does not exist, a {@code null} value is returned. + * + * @see Index Expressions */ public final class IndexExpression extends JmespathExpression { diff --git a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/LiteralExpression.java b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/LiteralExpression.java index 3fc484efff0..0c560bab841 100644 --- a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/LiteralExpression.java +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/LiteralExpression.java @@ -140,7 +140,7 @@ public RuntimeType getType() { } else if (isNullValue()) { return RuntimeType.NULL; } else if (this == EXPREF) { - return RuntimeType.EXPRESSION_REFERENCE; + return RuntimeType.EXPRESSION; } else { return RuntimeType.ANY; } @@ -155,7 +155,7 @@ public RuntimeType getType() { * @return Returns the object field value. */ public LiteralExpression getObjectField(String name) { - Map values = asObjectValue(); + Map values = expectObjectValue(); return values.containsKey(name) ? new LiteralExpression(values.get(name)) : new LiteralExpression(null); @@ -169,7 +169,7 @@ public LiteralExpression getObjectField(String name) { * @return Returns true if the object contains the given key. */ public boolean hasObjectField(String name) { - return asObjectValue().containsKey(name); + return expectObjectValue().containsKey(name); } /** @@ -182,7 +182,7 @@ public boolean hasObjectField(String name) { * @return Returns the array value. */ public LiteralExpression getArrayIndex(int index) { - List values = asArrayValue(); + List values = expectArrayValue(); if (index < 0) { index = values.size() + index; @@ -253,7 +253,7 @@ public boolean isNullValue() { * @return Returns the string value. * @throws JmespathException if the value is not a string. */ - public String asStringValue() { + public String expectStringValue() { if (value instanceof String) { return (String) value; } @@ -267,7 +267,7 @@ public String asStringValue() { * @return Returns the number value. * @throws JmespathException if the value is not a number. */ - public Number asNumberValue() { + public Number expectNumberValue() { if (value instanceof Number) { return (Number) value; } @@ -281,7 +281,7 @@ public Number asNumberValue() { * @return Returns the boolean value. * @throws JmespathException if the value is not a boolean. */ - public boolean asBooleanValue() { + public boolean expectBooleanValue() { if (value instanceof Boolean) { return (Boolean) value; } @@ -296,7 +296,7 @@ public boolean asBooleanValue() { * @throws JmespathException if the value is not an array. */ @SuppressWarnings("unchecked") - public List asArrayValue() { + public List expectArrayValue() { try { return (List) value; } catch (ClassCastException e) { @@ -311,7 +311,7 @@ public List asArrayValue() { * @throws JmespathException if the value is not an object. */ @SuppressWarnings("unchecked") - public Map asObjectValue() { + public Map expectObjectValue() { try { return (Map) value; } catch (ClassCastException e) { @@ -328,16 +328,16 @@ public boolean isTruthy() { switch (getType()) { case ANY: // just assume it's true. case NUMBER: // number is always true - case EXPRESSION_REFERENCE: // references are always true + case EXPRESSION: // references are always true return true; case STRING: - return !asStringValue().isEmpty(); + return !expectStringValue().isEmpty(); case ARRAY: - return !asArrayValue().isEmpty(); + return !expectArrayValue().isEmpty(); case OBJECT: - return !asObjectValue().isEmpty(); + return !expectObjectValue().isEmpty(); case BOOLEAN: - return asBooleanValue(); + return expectBooleanValue(); default: return false; } diff --git a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/MultiSelectHashExpression.java b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/MultiSelectHashExpression.java index 7ebc5eba081..6489c07408e 100644 --- a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/MultiSelectHashExpression.java +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/MultiSelectHashExpression.java @@ -22,13 +22,15 @@ /** * Creates an object using key-value pairs. + * + * @see MultiSelect Hash */ public final class MultiSelectHashExpression extends JmespathExpression { private final Map expressions; - public MultiSelectHashExpression(Map entries) { - this(entries, 1, 1); + public MultiSelectHashExpression(Map expressions) { + this(expressions, 1, 1); } public MultiSelectHashExpression(Map expressions, int line, int column) { diff --git a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/MultiSelectListExpression.java b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/MultiSelectListExpression.java index c29cded010c..0ff7b7864b0 100644 --- a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/MultiSelectListExpression.java +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/MultiSelectListExpression.java @@ -22,6 +22,8 @@ /** * Selects one or more values into a created array. + * + * @see MultiSelect List */ public final class MultiSelectListExpression extends JmespathExpression { diff --git a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/NotExpression.java b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/NotExpression.java index ea0bc4436d3..f9aad195b1a 100644 --- a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/NotExpression.java +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/NotExpression.java @@ -26,8 +26,8 @@ public final class NotExpression extends JmespathExpression { private final JmespathExpression expression; - public NotExpression(JmespathExpression wrapped) { - this(wrapped, 1, 1); + public NotExpression(JmespathExpression expression) { + this(expression, 1, 1); } public NotExpression(JmespathExpression expression, int line, int column) { diff --git a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/ObjectProjectionExpression.java b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/ObjectProjectionExpression.java index 316103cceae..3a71317f47a 100644 --- a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/ObjectProjectionExpression.java +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/ObjectProjectionExpression.java @@ -20,6 +20,13 @@ /** * A projection of object values. + * + *

If the left AST expression does not return an object, then the + * result of the projection is a {@code null} value. Otherwise, the + * object values are each yielded to the right AST expression, + * building up a list of results. + * + * @see Wildcard Expressions */ public final class ObjectProjectionExpression extends ProjectionExpression { diff --git a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/OrExpression.java b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/OrExpression.java index 2b1d7fa67fc..e13b27f4c22 100644 --- a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/OrExpression.java +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/OrExpression.java @@ -20,6 +20,8 @@ /** * Or expression that returns the expression that returns a truthy value. + * + * @see Or Expressions */ public final class OrExpression extends BinaryExpression { diff --git a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/ProjectionExpression.java b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/ProjectionExpression.java index 7859899312f..363ff3f35a5 100644 --- a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/ProjectionExpression.java +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/ProjectionExpression.java @@ -21,6 +21,11 @@ /** * Iterates over each element in the array returned from the left expression, * passes it to the right expression, and returns the aggregated results. + * + *

This AST node is created when parsing expressions like {@code [*]}, + * {@code []}, and {@code [1:1]}. + * + * @see Wildcard Expressions */ public class ProjectionExpression extends BinaryExpression { diff --git a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/SliceExpression.java b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/SliceExpression.java index 42e3d78f594..090047530e2 100644 --- a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/SliceExpression.java +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/SliceExpression.java @@ -23,6 +23,8 @@ /** * Represents a slice expression, containing an optional zero-based * start offset, zero-based stop offset, and step. + * + * @see Slices */ public final class SliceExpression extends JmespathExpression { diff --git a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/Subexpression.java b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/Subexpression.java index e5e90ad7306..63ed814ded8 100644 --- a/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/Subexpression.java +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/Subexpression.java @@ -20,6 +20,12 @@ /** * Visits the left expression and passes its result to the right expression. + * + *

This AST node is used for both sub-expressions and pipe-expressions in + * the JMESPath specification. + * + * @see SubExpressions + * @see Pipe expressions */ public final class Subexpression extends BinaryExpression { diff --git a/smithy-jmespath/src/test/java/software/amazon/smithy/jmespath/LexerTest.java b/smithy-jmespath/src/test/java/software/amazon/smithy/jmespath/LexerTest.java index 037506a4278..0f057b960be 100644 --- a/smithy-jmespath/src/test/java/software/amazon/smithy/jmespath/LexerTest.java +++ b/smithy-jmespath/src/test/java/software/amazon/smithy/jmespath/LexerTest.java @@ -48,7 +48,7 @@ public void tokenizesField() { Token token = tokens.next(); assertThat(token.type, equalTo(TokenType.IDENTIFIER)); - assertThat(token.value.asStringValue(), equalTo("foo_123_FOO")); + assertThat(token.value.expectStringValue(), equalTo("foo_123_FOO")); assertThat(token.line, equalTo(1)); assertThat(token.column, equalTo(1)); @@ -66,7 +66,7 @@ public void tokenizesSubexpression() { assertThat(tokens, hasSize(4)); assertThat(tokens.get(0).type, equalTo(TokenType.IDENTIFIER)); - assertThat(tokens.get(0).value.asStringValue(), equalTo("foo")); + assertThat(tokens.get(0).value.expectStringValue(), equalTo("foo")); assertThat(tokens.get(0).line, equalTo(1)); assertThat(tokens.get(0).column, equalTo(1)); @@ -75,7 +75,7 @@ public void tokenizesSubexpression() { assertThat(tokens.get(1).column, equalTo(4)); assertThat(tokens.get(2).type, equalTo(TokenType.IDENTIFIER)); - assertThat(tokens.get(2).value.asStringValue(), equalTo("bar")); + assertThat(tokens.get(2).value.expectStringValue(), equalTo("bar")); assertThat(tokens.get(2).line, equalTo(1)); assertThat(tokens.get(2).column, equalTo(5)); @@ -90,7 +90,7 @@ public void tokenizesJsonArray() { assertThat(tokens, hasSize(2)); assertThat(tokens.get(0).type, equalTo(TokenType.LITERAL)); - assertThat(tokens.get(0).value.asArrayValue(), equalTo(Arrays.asList(1.0, true, false, null, -2.0, "hi"))); + assertThat(tokens.get(0).value.expectArrayValue(), equalTo(Arrays.asList(1.0, true, false, null, -2.0, "hi"))); assertThat(tokens.get(0).line, equalTo(1)); assertThat(tokens.get(0).column, equalTo(1)); @@ -110,7 +110,7 @@ public void tokenizesEmptyJsonArray() { assertThat(tokens, hasSize(2)); assertThat(tokens.get(0).type, equalTo(TokenType.LITERAL)); - assertThat(tokens.get(0).value.asArrayValue(), empty()); + assertThat(tokens.get(0).value.expectArrayValue(), empty()); assertThat(tokens.get(0).line, equalTo(1)); assertThat(tokens.get(0).column, equalTo(1)); @@ -149,7 +149,7 @@ public void tokenizesJsonObject() { assertThat(tokens, hasSize(2)); assertThat(tokens.get(0).type, equalTo(TokenType.LITERAL)); - Map obj = tokens.get(0).value.asObjectValue(); + Map obj = tokens.get(0).value.expectObjectValue(); assertThat(obj.entrySet(), hasSize(2)); assertThat(obj.keySet(), contains("foo", "bar")); assertThat(obj.get("foo"), equalTo(true)); @@ -168,7 +168,7 @@ public void tokenizesEmptyJsonObject() { assertThat(tokens, hasSize(2)); assertThat(tokens.get(0).type, equalTo(TokenType.LITERAL)); - assertThat(tokens.get(0).value.asObjectValue().entrySet(), empty()); + assertThat(tokens.get(0).value.expectObjectValue().entrySet(), empty()); assertThat(tokens.get(0).line, equalTo(1)); assertThat(tokens.get(0).column, equalTo(1)); @@ -227,7 +227,7 @@ public void canEscapeTicksInJsonLiteralStrings() { assertThat(tokens, hasSize(2)); assertThat(tokens.get(0).type, equalTo(TokenType.LITERAL)); - assertThat(tokens.get(0).value.asStringValue(), equalTo("`")); + assertThat(tokens.get(0).value.expectStringValue(), equalTo("`")); assertThat(tokens.get(0).line, equalTo(1)); assertThat(tokens.get(0).column, equalTo(1)); @@ -249,12 +249,12 @@ public void parsesQuotedString() { assertThat(tokens, hasSize(3)); assertThat(tokens.get(0).type, equalTo(TokenType.IDENTIFIER)); - assertThat(tokens.get(0).value.asStringValue(), equalTo("foo")); + assertThat(tokens.get(0).value.expectStringValue(), equalTo("foo")); assertThat(tokens.get(0).line, equalTo(1)); assertThat(tokens.get(0).column, equalTo(1)); assertThat(tokens.get(1).type, equalTo(TokenType.IDENTIFIER)); - assertThat(tokens.get(1).value.asStringValue(), equalTo("")); + assertThat(tokens.get(1).value.expectStringValue(), equalTo("")); assertThat(tokens.get(1).line, equalTo(1)); assertThat(tokens.get(1).column, equalTo(7)); @@ -274,7 +274,7 @@ public void parsesQuotedStringEscapes() { assertThat(tokens, hasSize(2)); assertThat(tokens.get(0).type, equalTo(TokenType.IDENTIFIER)); - assertThat(tokens.get(0).value.asStringValue(), equalTo("\" \n \t \r \f \b / \\ ")); + assertThat(tokens.get(0).value.expectStringValue(), equalTo("\" \n \t \r \f \b / \\ ")); assertThat(tokens.get(0).line, equalTo(1)); assertThat(tokens.get(0).column, equalTo(1)); @@ -289,7 +289,7 @@ public void parsesQuotedStringValidHex() { assertThat(tokens, hasSize(2)); assertThat(tokens.get(0).type, equalTo(TokenType.IDENTIFIER)); - assertThat(tokens.get(0).value.asStringValue(), equalTo("\n\n")); + assertThat(tokens.get(0).value.expectStringValue(), equalTo("\n\n")); assertThat(tokens.get(0).line, equalTo(1)); assertThat(tokens.get(0).column, equalTo(1)); @@ -479,17 +479,17 @@ public void parsesNumbers() { assertThat(tokens, hasSize(4)); assertThat(tokens.get(0).type, equalTo(TokenType.NUMBER)); - assertThat(tokens.get(0).value.asNumberValue().doubleValue(), equalTo(123.0)); + assertThat(tokens.get(0).value.expectNumberValue().doubleValue(), equalTo(123.0)); assertThat(tokens.get(0).line, equalTo(1)); assertThat(tokens.get(0).column, equalTo(1)); assertThat(tokens.get(1).type, equalTo(TokenType.NUMBER)); - assertThat(tokens.get(1).value.asNumberValue().doubleValue(), equalTo(-1.0)); + assertThat(tokens.get(1).value.expectNumberValue().doubleValue(), equalTo(-1.0)); assertThat(tokens.get(1).line, equalTo(1)); assertThat(tokens.get(1).column, equalTo(5)); assertThat(tokens.get(2).type, equalTo(TokenType.NUMBER)); - assertThat(tokens.get(2).value.asNumberValue().doubleValue(), equalTo(0.0)); + assertThat(tokens.get(2).value.expectNumberValue().doubleValue(), equalTo(0.0)); assertThat(tokens.get(2).line, equalTo(1)); assertThat(tokens.get(2).column, equalTo(8)); @@ -519,12 +519,12 @@ public void ignoresNonNumericExponents() { assertThat(tokens, hasSize(3)); assertThat(tokens.get(0).type, equalTo(TokenType.NUMBER)); - assertThat(tokens.get(0).value.asNumberValue().doubleValue(), equalTo(0.0)); + assertThat(tokens.get(0).value.expectNumberValue().doubleValue(), equalTo(0.0)); assertThat(tokens.get(0).line, equalTo(1)); assertThat(tokens.get(0).column, equalTo(1)); assertThat(tokens.get(1).type, equalTo(TokenType.IDENTIFIER)); - assertThat(tokens.get(1).value.asStringValue(), equalTo("a")); + assertThat(tokens.get(1).value.expectStringValue(), equalTo("a")); assertThat(tokens.get(1).line, equalTo(1)); assertThat(tokens.get(1).column, equalTo(4)); @@ -539,12 +539,12 @@ public void parsesComplexNumbers() { assertThat(tokens, hasSize(3)); assertThat(tokens.get(0).type, equalTo(TokenType.NUMBER)); - assertThat(tokens.get(0).value.asNumberValue().doubleValue(), equalTo(123.009e12)); + assertThat(tokens.get(0).value.expectNumberValue().doubleValue(), equalTo(123.009e12)); assertThat(tokens.get(0).line, equalTo(1)); assertThat(tokens.get(0).column, equalTo(1)); assertThat(tokens.get(1).type, equalTo(TokenType.NUMBER)); - assertThat(tokens.get(1).value.asNumberValue().doubleValue(), equalTo(-001.109e-12)); + assertThat(tokens.get(1).value.expectNumberValue().doubleValue(), equalTo(-001.109e-12)); assertThat(tokens.get(1).line, equalTo(1)); assertThat(tokens.get(1).column, equalTo(13)); @@ -564,17 +564,17 @@ public void parsesRawStringLiteral() { assertThat(tokens, hasSize(4)); assertThat(tokens.get(0).type, equalTo(TokenType.LITERAL)); - assertThat(tokens.get(0).value.asStringValue(), equalTo("foo")); + assertThat(tokens.get(0).value.expectStringValue(), equalTo("foo")); assertThat(tokens.get(0).line, equalTo(1)); assertThat(tokens.get(0).column, equalTo(1)); assertThat(tokens.get(1).type, equalTo(TokenType.LITERAL)); - assertThat(tokens.get(1).value.asStringValue(), equalTo("foo's")); + assertThat(tokens.get(1).value.expectStringValue(), equalTo("foo's")); assertThat(tokens.get(1).line, equalTo(1)); assertThat(tokens.get(1).column, equalTo(7)); assertThat(tokens.get(2).type, equalTo(TokenType.LITERAL)); - assertThat(tokens.get(2).value.asStringValue(), equalTo("foo\\a")); + assertThat(tokens.get(2).value.expectStringValue(), equalTo("foo\\a")); assertThat(tokens.get(2).line, equalTo(1)); assertThat(tokens.get(2).column, equalTo(16)); @@ -589,7 +589,7 @@ public void parsesEmptyRawString() { assertThat(tokens, hasSize(2)); assertThat(tokens.get(0).type, equalTo(TokenType.LITERAL)); - assertThat(tokens.get(0).value.asStringValue(), equalTo("")); + assertThat(tokens.get(0).value.expectStringValue(), equalTo("")); assertThat(tokens.get(0).line, equalTo(1)); assertThat(tokens.get(0).column, equalTo(1)); diff --git a/smithy-jmespath/src/test/java/software/amazon/smithy/jmespath/ParserTest.java b/smithy-jmespath/src/test/java/software/amazon/smithy/jmespath/ParserTest.java index c42f0af359d..021758f82fa 100644 --- a/smithy-jmespath/src/test/java/software/amazon/smithy/jmespath/ParserTest.java +++ b/smithy-jmespath/src/test/java/software/amazon/smithy/jmespath/ParserTest.java @@ -27,9 +27,9 @@ import org.junit.jupiter.api.Test; import software.amazon.smithy.jmespath.ast.AndExpression; import software.amazon.smithy.jmespath.ast.ComparatorType; -import software.amazon.smithy.jmespath.ast.ComparisonExpression; +import software.amazon.smithy.jmespath.ast.ComparatorExpression; import software.amazon.smithy.jmespath.ast.CurrentExpression; -import software.amazon.smithy.jmespath.ast.ExpressionReferenceExpression; +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; @@ -149,7 +149,7 @@ public void parsesNudMultiSelectHash() { @Test public void parsesNudAmpersand() { assertThat(JmespathExpression.parse("&foo[1]"), equalTo( - new ExpressionReferenceExpression( + new ExpressionTypeExpression( new Subexpression( new FieldExpression("foo"), new IndexExpression(1))))); @@ -169,7 +169,7 @@ public void parsesNudFilter() { assertThat(JmespathExpression.parse("[?foo == `true`]"), equalTo( new FilterProjectionExpression( new CurrentExpression(), - new ComparisonExpression( + new ComparatorExpression( ComparatorType.EQUAL, new FieldExpression("foo"), new LiteralExpression(true)), @@ -182,7 +182,7 @@ public void parsesNudFilterWithComparators() { assertThat(JmespathExpression.parse("[?foo " + type + " `true`]"), equalTo( new FilterProjectionExpression( new CurrentExpression(), - new ComparisonExpression( + new ComparatorExpression( type, new FieldExpression("foo"), new LiteralExpression(true)), @@ -295,7 +295,7 @@ public void parsesLedFilterProjection() { assertThat(JmespathExpression.parse("a[?b > c].d"), equalTo( new FilterProjectionExpression( new FieldExpression("a"), - new ComparisonExpression( + new ComparatorExpression( ComparatorType.GREATER_THAN, new FieldExpression("b"), new FieldExpression("c")), @@ -319,7 +319,7 @@ public void parsesLedProjectionIntoFilterProjection() { new FieldExpression("a"), new FilterProjectionExpression( new CurrentExpression(), - new ComparisonExpression( + new ComparatorExpression( ComparatorType.EQUAL, new FieldExpression("foo"), new FieldExpression("bar")), diff --git a/smithy-jmespath/src/test/java/software/amazon/smithy/jmespath/RunnerTest.java b/smithy-jmespath/src/test/java/software/amazon/smithy/jmespath/RunnerTest.java index c382fcd5a08..bd419f69f4f 100644 --- a/smithy-jmespath/src/test/java/software/amazon/smithy/jmespath/RunnerTest.java +++ b/smithy-jmespath/src/test/java/software/amazon/smithy/jmespath/RunnerTest.java @@ -22,8 +22,6 @@ public void validTests() { for (ExpressionProblem problem : expression.lint().getProblems()) { if (problem.severity == ExpressionProblem.Severity.ERROR) { Assertions.fail("Did not expect an ERROR for line: " + line + "\n" + problem); - } else { - System.out.println(problem); } } } catch (JmespathException e) { @@ -54,7 +52,7 @@ private List readFile(InputStream stream) { return line; } }) - .map(line -> Lexer.tokenize(line).next().value.asStringValue()) + .map(line -> Lexer.tokenize(line).next().value.expectStringValue()) .collect(Collectors.toList()); } } diff --git a/smithy-jmespath/src/test/java/software/amazon/smithy/jmespath/TypeCheckerTest.java b/smithy-jmespath/src/test/java/software/amazon/smithy/jmespath/TypeCheckerTest.java index f4dbdf60862..2bbc6fdf77e 100644 --- a/smithy-jmespath/src/test/java/software/amazon/smithy/jmespath/TypeCheckerTest.java +++ b/smithy-jmespath/src/test/java/software/amazon/smithy/jmespath/TypeCheckerTest.java @@ -328,7 +328,7 @@ public void comparesNulls() { @Test public void cannotCompareExpref() { - assertThat(check("(&foo) == (&foo)"), contains("[WARNING] Invalid comparator '==' for expression_reference (1:11)")); + assertThat(check("(&foo) == (&foo)"), contains("[WARNING] Invalid comparator '==' for expression (1:11)")); } @Test diff --git a/smithy-jmespath/src/test/java/software/amazon/smithy/jmespath/ast/LiteralExpressionTest.java b/smithy-jmespath/src/test/java/software/amazon/smithy/jmespath/ast/LiteralExpressionTest.java index ecdccb370ac..41aa5e2bc0e 100644 --- a/smithy-jmespath/src/test/java/software/amazon/smithy/jmespath/ast/LiteralExpressionTest.java +++ b/smithy-jmespath/src/test/java/software/amazon/smithy/jmespath/ast/LiteralExpressionTest.java @@ -39,14 +39,14 @@ public void containsNullValues() { public void throwsWhenNotString() { LiteralExpression node = new LiteralExpression(10); - Assertions.assertThrows(JmespathException.class, node::asStringValue); + Assertions.assertThrows(JmespathException.class, node::expectStringValue); } @Test public void getsAsString() { LiteralExpression node = new LiteralExpression("foo"); - node.asStringValue(); + node.expectStringValue(); assertThat(node.isStringValue(), is(true)); assertThat(node.isNullValue(), is(false)); // not null assertThat(node.getType(), equalTo(RuntimeType.STRING)); @@ -56,14 +56,14 @@ public void getsAsString() { public void throwsWhenNotArray() { LiteralExpression node = new LiteralExpression("hi"); - Assertions.assertThrows(JmespathException.class, node::asArrayValue); + Assertions.assertThrows(JmespathException.class, node::expectArrayValue); } @Test public void getsAsArray() { LiteralExpression node = new LiteralExpression(Collections.emptyList()); - node.asArrayValue(); + node.expectArrayValue(); assertThat(node.isArrayValue(), is(true)); assertThat(node.getType(), equalTo(RuntimeType.ARRAY)); } @@ -82,14 +82,14 @@ public void getsNegativeArrayIndex() { public void throwsWhenNotNumber() { LiteralExpression node = new LiteralExpression("hi"); - Assertions.assertThrows(JmespathException.class, node::asNumberValue); + Assertions.assertThrows(JmespathException.class, node::expectNumberValue); } @Test public void getsAsNumber() { LiteralExpression node = new LiteralExpression(10); - node.asNumberValue(); + node.expectNumberValue(); assertThat(node.isNumberValue(), is(true)); assertThat(node.getType(), equalTo(RuntimeType.NUMBER)); } @@ -98,14 +98,14 @@ public void getsAsNumber() { public void throwsWhenNotBoolean() { LiteralExpression node = new LiteralExpression("hi"); - Assertions.assertThrows(JmespathException.class, node::asBooleanValue); + Assertions.assertThrows(JmespathException.class, node::expectBooleanValue); } @Test public void getsAsBoolean() { LiteralExpression node = new LiteralExpression(true); - node.asBooleanValue(); + node.expectBooleanValue(); assertThat(node.isBooleanValue(), is(true)); assertThat(node.getType(), equalTo(RuntimeType.BOOLEAN)); } @@ -114,7 +114,7 @@ public void getsAsBoolean() { public void getsAsBoxedBoolean() { LiteralExpression node = new LiteralExpression(new Boolean(true)); - node.asBooleanValue(); + node.expectBooleanValue(); assertThat(node.isBooleanValue(), is(true)); } @@ -122,21 +122,21 @@ public void getsAsBoxedBoolean() { public void throwsWhenNotMap() { LiteralExpression node = new LiteralExpression("hi"); - Assertions.assertThrows(JmespathException.class, node::asObjectValue); + Assertions.assertThrows(JmespathException.class, node::expectObjectValue); } @Test public void getsAsMap() { LiteralExpression node = new LiteralExpression(Collections.emptyMap()); - node.asObjectValue(); + node.expectObjectValue(); assertThat(node.isObjectValue(), is(true)); assertThat(node.getType(), equalTo(RuntimeType.OBJECT)); } @Test public void expressionReferenceTypeIsExpref() { - assertThat(LiteralExpression.EXPREF.getType(), equalTo(RuntimeType.EXPRESSION_REFERENCE)); + assertThat(LiteralExpression.EXPREF.getType(), equalTo(RuntimeType.EXPRESSION)); } @Test From cc78e21346ab79aa29aaf3ab9ad7b06321d2087b Mon Sep 17 00:00:00 2001 From: Michael Dowling Date: Fri, 30 Oct 2020 15:16:19 -0700 Subject: [PATCH 3/5] Add support for "waiters" Waiters are a client-side abstraction used to poll a resource until a desired state is reached, or until it is determined that the resource will never enter into the desired state. Waiters have been available in AWS SDKs since around 2012, and are now part of Smithy as an additional specification. Note that this PR relies on smithy-jmespath. --- docs/source/1.0/spec/index.rst | 1 + docs/source/1.0/spec/waiters.rst | 809 ++++++++++++++++++ docs/source/conf.py | 1 + settings.gradle | 1 + smithy-waiters/build.gradle | 26 + .../amazon/smithy/waiters/Acceptor.java | 100 +++ .../amazon/smithy/waiters/AcceptorState.java | 60 ++ .../amazon/smithy/waiters/Matcher.java | 374 ++++++++ .../waiters/ModelRuntimeTypeGenerator.java | 271 ++++++ .../amazon/smithy/waiters/PathComparator.java | 74 ++ .../amazon/smithy/waiters/PathMatcher.java | 120 +++ .../amazon/smithy/waiters/WaitableTrait.java | 118 +++ .../waiters/WaitableTraitValidator.java | 73 ++ .../amazon/smithy/waiters/Waiter.java | 214 +++++ .../waiters/WaiterMatcherValidator.java | 213 +++++ ...re.amazon.smithy.model.traits.TraitService | 1 + ...e.amazon.smithy.model.validation.Validator | 1 + .../main/resources/META-INF/smithy/manifest | 1 + .../resources/META-INF/smithy/waiters.smithy | 168 ++++ .../ModelRuntimeTypeGeneratorTest.java | 93 ++ .../amazon/smithy/waiters/RunnerTest.java | 35 + .../amazon/smithy/waiters/WaiterTest.java | 56 ++ ...cannot-wait-on-streaming-operations.errors | 2 + ...cannot-wait-on-streaming-operations.smithy | 44 + ...emits-danger-and-warning-typechecks.errors | 3 + ...emits-danger-and-warning-typechecks.smithy | 42 + .../input-output-on-bad-shapes.errors | 2 + .../input-output-on-bad-shapes.smithy | 36 + .../invalid-boolean-expected-value.errors | 1 + .../invalid-boolean-expected-value.smithy | 52 ++ .../errorfiles/invalid-errorType.errors | 1 + .../errorfiles/invalid-errorType.smithy | 28 + .../errorfiles/invalid-jmespath-syntax.errors | 3 + .../errorfiles/invalid-jmespath-syntax.smithy | 45 + .../errorfiles/invalid-return-types.errors | 4 + .../errorfiles/invalid-return-types.smithy | 58 ++ .../invalid-structure-member-access.errors | 2 + .../invalid-structure-member-access.smithy | 47 + .../minDelay-greater-than-maxDelay.errors | 1 + .../minDelay-greater-than-maxDelay.smithy | 46 + .../errorfiles/not-uppercamelcase.errors | 1 + .../errorfiles/not-uppercamelcase.smithy | 33 + .../waiters/errorfiles/valid-composite.errors | 0 .../waiters/errorfiles/valid-composite.smithy | 65 ++ .../waiters/errorfiles/valid-inputpath.errors | 0 .../waiters/errorfiles/valid-inputpath.smithy | 48 ++ .../waiters/errorfiles/valid-waiters.errors | 0 .../waiters/errorfiles/valid-waiters.smithy | 126 +++ .../waiter-missing-success-state.errors | 1 + .../waiter-missing-success-state.smithy | 23 + .../smithy/waiters/model-runtime-types.smithy | 66 ++ 51 files changed, 3590 insertions(+) create mode 100644 docs/source/1.0/spec/waiters.rst create mode 100644 smithy-waiters/build.gradle create mode 100644 smithy-waiters/src/main/java/software/amazon/smithy/waiters/Acceptor.java create mode 100644 smithy-waiters/src/main/java/software/amazon/smithy/waiters/AcceptorState.java create mode 100644 smithy-waiters/src/main/java/software/amazon/smithy/waiters/Matcher.java create mode 100644 smithy-waiters/src/main/java/software/amazon/smithy/waiters/ModelRuntimeTypeGenerator.java create mode 100644 smithy-waiters/src/main/java/software/amazon/smithy/waiters/PathComparator.java create mode 100644 smithy-waiters/src/main/java/software/amazon/smithy/waiters/PathMatcher.java create mode 100644 smithy-waiters/src/main/java/software/amazon/smithy/waiters/WaitableTrait.java create mode 100644 smithy-waiters/src/main/java/software/amazon/smithy/waiters/WaitableTraitValidator.java create mode 100644 smithy-waiters/src/main/java/software/amazon/smithy/waiters/Waiter.java create mode 100644 smithy-waiters/src/main/java/software/amazon/smithy/waiters/WaiterMatcherValidator.java create mode 100644 smithy-waiters/src/main/resources/META-INF/services/software.amazon.smithy.model.traits.TraitService create mode 100644 smithy-waiters/src/main/resources/META-INF/services/software.amazon.smithy.model.validation.Validator create mode 100644 smithy-waiters/src/main/resources/META-INF/smithy/manifest create mode 100644 smithy-waiters/src/main/resources/META-INF/smithy/waiters.smithy create mode 100644 smithy-waiters/src/test/java/software/amazon/smithy/waiters/ModelRuntimeTypeGeneratorTest.java create mode 100644 smithy-waiters/src/test/java/software/amazon/smithy/waiters/RunnerTest.java create mode 100644 smithy-waiters/src/test/java/software/amazon/smithy/waiters/WaiterTest.java create mode 100644 smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/cannot-wait-on-streaming-operations.errors create mode 100644 smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/cannot-wait-on-streaming-operations.smithy create mode 100644 smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/emits-danger-and-warning-typechecks.errors create mode 100644 smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/emits-danger-and-warning-typechecks.smithy create mode 100644 smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/input-output-on-bad-shapes.errors create mode 100644 smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/input-output-on-bad-shapes.smithy create mode 100644 smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-boolean-expected-value.errors create mode 100644 smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-boolean-expected-value.smithy create mode 100644 smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-errorType.errors create mode 100644 smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-errorType.smithy create mode 100644 smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-jmespath-syntax.errors create mode 100644 smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-jmespath-syntax.smithy create mode 100644 smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-return-types.errors create mode 100644 smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-return-types.smithy create mode 100644 smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-structure-member-access.errors create mode 100644 smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-structure-member-access.smithy create mode 100644 smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/minDelay-greater-than-maxDelay.errors create mode 100644 smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/minDelay-greater-than-maxDelay.smithy create mode 100644 smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/not-uppercamelcase.errors create mode 100644 smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/not-uppercamelcase.smithy create mode 100644 smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/valid-composite.errors create mode 100644 smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/valid-composite.smithy create mode 100644 smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/valid-inputpath.errors create mode 100644 smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/valid-inputpath.smithy create mode 100644 smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/valid-waiters.errors create mode 100644 smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/valid-waiters.smithy create mode 100644 smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/waiter-missing-success-state.errors create mode 100644 smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/waiter-missing-success-state.smithy create mode 100644 smithy-waiters/src/test/resources/software/amazon/smithy/waiters/model-runtime-types.smithy diff --git a/docs/source/1.0/spec/index.rst b/docs/source/1.0/spec/index.rst index cc5ee4963a4..1958d9f0bb7 100644 --- a/docs/source/1.0/spec/index.rst +++ b/docs/source/1.0/spec/index.rst @@ -29,6 +29,7 @@ Additional specifications :maxdepth: 1 http-protocol-compliance-tests + waiters mqtt diff --git a/docs/source/1.0/spec/waiters.rst b/docs/source/1.0/spec/waiters.rst new file mode 100644 index 00000000000..9742bc54953 --- /dev/null +++ b/docs/source/1.0/spec/waiters.rst @@ -0,0 +1,809 @@ +.. _waiters: + +======= +Waiters +======= + +Waiters are a client-side abstraction used to poll a resource until a desired +state is reached, or until it is determined that the resource will never +enter into the desired state. This is a common task when working with +services that are eventually consistent like Amazon S3 or services that +asynchronously create resources like Amazon EC2. Writing logic to +continuously poll the status of a resource can be cumbersome and +error-prone. The goal of waiters is to move this responsibility out of +customer code and onto service teams who know their service best. + +For example, waiters can be used in code to turn the workflow of waiting +for an Amazon EC2 instance to be terminated into something like the +following client pseudocode: + +.. code-block:: java + + InstanceTerminatedWaiter waiter = InstanceTerminatedWaiter.builder() + .client(myClient) + .instanceIds(Collections.singletonList("i-foo")) + .totalAllowedWaitTime(10, Duration.MINUTES) + .wait(); + + +.. _smithy.waiters#waitable-trait: + +``smithy.waiters#waitable`` trait +================================= + +Waiters are defined on :ref:`operations ` using the +``smithy.waiters#waitable`` trait. + +Trait summary + Indicates that an operation has various named "waiters" that can be used + to poll a resource until it enters a desired state. +Trait selector + ``operation :not(-[input, output]-> structure > member > union[trait|streaming])`` + + (Operations that do not use :ref:`event streams ` in their input or output) +Trait value + A ``map`` of :ref:`waiter names ` to + :ref:`Waiter structures `. + +The following example defines a waiter that waits until an Amazon S3 bucket +exists: + +.. code-block:: smithy + :emphasize-lines: 3 + + namespace com.amazonaws.s3 + + @waitable( + BucketExists: { + documentation: "Wait until a bucket exists", + acceptors: [ + { + state: "success", + matcher: { + success: true + } + }, + { + state: "retry", + matcher: { + errorType: "NotFound" + } + } + ] + } + ) + operation HeadBucket { + input: HeadBucketInput, + output: HeadBucketOutput, + errors: [NotFound] + } + +Applying the steps defined in `Waiter workflow`_ to the above example, +a client performs the following steps: + +1. A ``HeadBucket`` operation is created, given the necessary input + parameters, and sent to the service. +2. If the operation completes successfully, the waiter transitions to the + ``success`` state and terminates. This is defined in the first acceptor + of the waiter that uses the ``success`` matcher. +3. If the operation encounters an error named ``NotFound``, the waiter + transitions to the ``retry`` state. +4. If the operation fails with any other error, the waiter transitions to + the ``failure`` state and terminates. +5. The waiter is in the ``retry`` state and continues at step 1 after + delaying with exponential backoff until the total allowed time to wait + is exceeded. + + +.. _waiter-names: + +Waiter names +------------ + +Waiter names MUST be defined using UpperCamelCase and only contain +alphanumeric characters. That is, waiters MUST adhere to the following +ABNF: + +.. code-block:: abnf + + waiter-name: upper-alpha *(ALPHA / DIGIT) + upper-alpha: %x41-5A ; A-Z + +.. seealso:: :ref:`waiter-best-practices` for additional best practices + to follow when naming waiters. + + +Waiter workflow +=============== + +Implementations MUST require callers to provide the total amount of time +they are willing to wait for a waiter to complete. Requiring the caller +to set a deadline removes any surprises as to how long a waiter can +potentially take to complete. + +While the total execution time of a waiter is less than the allowed time, +waiter implementations perform the following steps: + +1. Call the operation the :ref:`smithy.waiters#waitable-trait` is attached + to using user-provided input for the operation. Any errors that can be + encountered by the operation must be caught so that they can be inspected. +2. If the total time of the waiter exceeds the allowed time, the waiter + SHOULD attempt to cancel any in-progress requests and MUST transition to a + to a terminal ``failure`` state. +3. For every :ref:`acceptor ` in the waiter: + + 1. If the acceptor :ref:`matcher ` is a match, transition + to the :ref:`state ` of the acceptor. + 2. If the acceptor transitions the waiter to the ``retry`` state, then + continue to step 5. + 3. Stop waiting if the acceptor transitions the waiter to the ``success`` + or ``failure`` state. + +4. If none of the acceptors are matched and an error was encountered while + calling the operation, then transition to the ``failure`` state and stop + waiting. +5. Transition the waiter to the ``retry`` state, follow the process + described in :ref:`waiter-retries`, and continue to step 1. + + +.. _waiter-retries: + +Waiter retries +-------------- + +Waiter implementations MUST delay for a period of time before attempting a +retry. The amount of time a waiter delays between retries is computed using +`exponential backoff`_ through the following algorithm: + +* Let ``attempt`` be the number retry attempts. +* Let ``minDelay`` be the minimum amount of time to delay between retries in + seconds, specified by the ``minDelay`` property of a + :ref:`waiter ` with a default of 2. +* Let ``maxDelay`` be the maximum amount of time to delay between retries in + seconds, specified by the ``maxDelay`` property of a + :ref:`waiter ` with a default of 120. +* Let ``min`` be a function that returns the smaller of two integers. +* Let ``max`` be a function that returns the larger of two integers. +* Let ``maxWaitTime`` be the amount of time in seconds a user is willing to + wait for a waiter to complete. +* Let ``remainingTime`` be the amount of seconds remaining before the waiter + has exceeded ``maxWaitTime``. + +.. code-block:: python + + delay = min(maxDelay, minDelay * 2 ** (attempt - 1)) + + if remainingTime - delay <= minDelay: + delay = remainingTime - minDelay + +If the computed ``delay`` subtracted from ``remainingTime`` is less than +or equal to ``minDelay``, then set ``delay`` to ``remainingTime`` minus +``minDelay`` and perform one last retry. This prevents a waiter from waiting +needlessly only to exceed ``maxWaitTime`` before issuing a final request. + +Using the default ``minDelay`` of 2, ``maxDelay`` of 120, a ``maxWaitTime`` +of 300 (or 5 minutes), and assuming that requests complete in 0 seconds +(for example purposes only), delays are computed as followed: + +.. list-table:: + :header-rows: 1 + + * - Retry ``attempt`` + - ``delay`` + - Cumulative time + - ``remainingTime`` + * - 1 + - 2 + - 2 + - 298 + * - 2 + - 4 + - 6 + - 294 + * - 3 + - 8 + - 14 + - 286 + * - 4 + - 16 + - 30 + - 270 + * - 5 + - 32 + - 62 + - 238 + * - 6 + - 64 + - 126 + - 174 + * - 7 + - 120 + - 254 + - 46 + * - 8 (last attempt) + - 44 + - 298 + - N/A + + +.. _waiter-structure: + +Waiter structure +================ + +A *waiter* defines a set of acceptors that are used to check if a resource +has entered into a desired state. + +.. list-table:: + :header-rows: 1 + :widths: 10 25 65 + + * - Property + - Type + - Description + * - documentation + - ``string`` + - Documentation about the waiter defined using CommonMark_. + * - acceptors + - ``[`` :ref:`Acceptor structure ` ``]`` + - **Required**. An ordered array of acceptors to check after executing + an operation. The list of ``acceptors`` MUST contain at least one + acceptor with a ``success`` state transition. + * - minDelay + - ``integer`` + - The minimum amount of time in seconds to delay between each retry. + This value defaults to ``2`` if not specified. If specified, this + value MUST be greater than or equal to 1 and less than or equal to + ``maxDelay``. + * - maxDelay + - ``integer`` + - The maximum amount of time in seconds to delay between each retry. + This value defaults to ``120`` if not specified (2 minutes). If + specified, this value MUST be greater than or equal to 1. + + +.. _waiter-acceptor: + +Acceptor structure +================== + +.. list-table:: + :header-rows: 1 + :widths: 10 25 65 + + * - Property + - Type + - Description + * - state + - ``string`` + - **Required**. The state the acceptor transitions to when matched. The + string value MUST be a valid :ref:`AcceptorState enum `. + * - matcher + - :ref:`Matcher structure ` + - **Required.** The matcher used to test if the resource is in a state + that matches the requirements needed for a state transition. + + +.. _waiter-acceptor-state: + +AcceptorState enum +================== + +Acceptors cause a waiter to transition into one of the following states: + +.. list-table:: + :header-rows: 1 + :widths: 20 80 + + * - Name + - Description + * - success + - The waiter successfully finished waiting. This is a terminal state + that causes the waiter to stop. + * - failure + - The waiter failed to enter into the desired state. This is a terminal + state that causes the waiter to stop. + * - retry + - The waiter will retry the operation. This state transition is + implicit if no accepter causes a state transition. + + +.. _waiter-matcher: + +Matcher union +============= + +A *matcher* defines how an acceptor determines if it matches the current +state of a resource. A matcher is a union where exactly one of the following +members MUST be set: + +.. list-table:: + :header-rows: 1 + :widths: 10 25 65 + + * - Property + - Type + - Description + * - input + - :ref:`PathMatcher structure ` + - Matches on the input of an operation using a JMESPath_ expression. + The ``input`` matcher MUST NOT be used on operations with no input. + This matcher is checked regardless of if an operation succeeds or + fails with an error. + * - output + - :ref:`PathMatcher structure ` + - Matches on the successful output of an operation using a + JMESPath_ expression. The ``output`` matcher MUST NOT be used on + operations with no output. This matcher is checked only if an + operation completes successfully. + * - success + - ``boolean`` + - 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. + * - errorType + - ``string`` + - Matches if an operation returns an error of an expected type. If an + absolute :ref:`shape ID ` is provided, the error is + matched only based on the name part of the shape ID. A relative shape + name MAY be provided to match errors that are not defined in the + model. + + The ``errorType`` matcher SHOULD refer to errors that are associated + with an operation through its ``errors`` property, though some + operations might need to refer to framework errors or lower-level + errors that are not defined in the model. + * - and + - ``[`` :ref:`Matcher ` ``]`` + - Matches if all matchers in the list match. The list MUST contain at + least one matcher. + * - or + - ``[`` :ref:`Matcher ` ``]`` + - Matches if any matchers in the list match. The list MUST contain at + least one matcher. + * - not + - :ref:`Matcher ` + - Matches if the given matcher is not a match. + + +.. _waiter-PathMatcher: + +PathMatcher structure +===================== + +The ``input`` and ``output`` matchers test the result of a JMESPath_ +expression against an expected value. These matchers are structures that +support the following members: + +.. list-table:: + :header-rows: 1 + :widths: 10 25 65 + + * - Property + - Type + - Description + * - path + - ``string`` + - **Required.** A JMESPath expression applied to the input or output + of an operation. + * - expected + - ``string`` + - **Required.** The expected return value of the expression. + * - comparator + - ``string`` + - **Required.** The comparator used to compare the result of the + ``expression`` with the ``expected`` value. The string value MUST + be a valid :ref:`PathComparator-enum`. + + +JMESPath data model +------------------- + +The data model exposed to JMESPath_ for input and output structures is +converted from Smithy types to `JMESPath types`_ using the following +conversion table: + +.. list-table:: + :header-rows: 1 + + * - Smithy type + - JMESPath type + * - blob + - string (base64 encoded) + * - boolean + - boolean + * - byte + - number + * - short + - number + * - integer + - number + * - long + - number [#fnumbers]_ + * - float + - number + * - double + - number + * - bigDecimal + - number [#fnumbers]_ + * - bigInteger + - number [#fnumbers]_ + * - string + - string + * - timestamp + - number [#ftimestamp]_ + * - document + - any type + * - list and set + - array + * - map + - object + * - structure + - object [#fstructure]_ + * - union + - object [#funion]_ + +.. rubric:: Footnotes + +.. [#fnumbers] ``long``, ``bigInteger``, ``bigDecimal`` are exposed as + numbers to JMESPath. If a value for one of these types truly exceeds + the value of a double (the native numeric type of JMESPath), then + querying these types in a waiter is a bad idea. +.. [#ftimestamp] ``timestamp`` values are represented in JMESPath expressions + as epoch seconds with optional decimal precision. This allows for + timestamp values to be used with relative comparators like ``<`` and ``>``. +.. [#fstructure] Structure members are referred to by member name and not + the data sent over the wire. For example, the :ref:`jsonname-trait` is not + respected in JMESPath expressions that select structure members. +.. [#funion] ``union`` values are represented exactly like structures except + only a single member is set to a non-null value. + + +JMESPath static analysis +------------------------ + +Smithy implementations that can statically analyze JMESPath expressions +MAY emit a :ref:`validation event ` with an event ID of +``WaitableTraitJmespathProblem`` and a :ref:`severity of DANGER ` +if one of the following problems are detected in an expression: + +1. A JMESPath expression does not return a value that matches the expected + return type of a :ref:`PathComparator-enum` +2. A JMESPath expression attempts to extract or operate on invalid model data. + +If such a problem is detected but is intentional, a +:ref:`suppression ` can be used to ignore the error. + + +.. _PathComparator-enum: + +PathComparator enum +=================== + +Each ``PathMatcher`` structure contains a ``comparator`` that is used to +check the result of a JMESPath expression against an expected value. A +comparator can be set to any of the following values: + +.. list-table:: + :header-rows: 1 + :widths: 20 60 20 + + * - Name + - Description + - Required JMESPath return type + * - stringEquals + - Matches if the return value of a JMESPath expression is a string + that is equal to an expected string. + - ``string`` + * - booleanEquals + - Matches if the return value of a JMESPath expression is a boolean. + The ``expected`` value of a ``PathMatcher`` MUST be set to "true" + or "false" to match the corresponding boolean value. + - ``boolean`` + * - allStringEquals + - Matches if the return value of a JMESPath expression is an array and + every value in the array is a string that equals an expected string. + - ``array`` of ``string`` + * - anyStringEquals + - Matches if the return value of a JMESPath expression is an array and + any value in the array is a string that equals an expected string. + - ``array`` of ``string`` + * - arrayEmpty + - Matches if the return value of a JMESPath expression is an empty + ``array``. + - ``array`` + + +Waiter examples +=============== + +This section provides examples for various features of waiters. + +The following example defines a ``ThingExists`` waiter that waits until the +``status`` member in the output of the ``GetThing`` operation returns +``"success"``. This example makes use of a "fail-fast"; in this example, if +a "Thing" has a ``failed`` status, then it can never enter the desired +``success`` state. To address this and prevent needlessly waiting on a +success state that can never happen, a ``failure`` state transition is +triggered if the ``status`` property equals ``failed``. + +.. code-block:: smithy + + namespace smithy.example + + use smithy.waiters#waitable + + @waitable( + ThingExists: { + description: "Waits until a thing has been created", + acceptors: [ + // Fail-fast if the thing transitions to a "failed" state. + { + state: "failure", + matcher: { + output: { + path: "status", + comparator: "stringEquals", + expected: "failed" + } + } + }, + // Succeed when the thing enters into a "success" state. + { + state: "success", + matcher: { + output: { + path: "status", + comparator: "stringEquals", + expected: "success" + } + } + } + ] + } + ) + operation GetThing { + input: GetThingInput, + output: GetThingOutput, + } + + structure GetThingInput { + @required + name: String, + } + + structure GetThingOutput { + status: String + } + +The ``and`` and ``not`` matchers can be composed together. The following +example waiter transitions into a failure state if the waiter encounters +any error other than ``NotFoundError``: + +.. code-block:: smithy + + namespace smithy.example + + use smithy.waiters#waitable + + @waitable( + FooExists: { + acceptors: [ + { + state: "failure", + matcher: { + and: [ + { + success: false + }, + { + not: { + errorType: "NotFoundError" + } + } + ] + } + }, + { + "state": "success", + matcher: { + success: true + } + } + ] + } + ) + operation GetFoo { + errors: [NotFoundError] + } + + +.. _waiter-best-practices: + +Waiter best-practices +===================== + +The following non-normative section outlines best practices for defining +and implementing waiters. + + +Keep JMESPath expressions simple +-------------------------------- + +Overly complex JMESPath_ expressions can easily lead to bugs. While static +analysis of JMESPath expressions can give some level of confidence in +expressions, it does not guarantee that the logic encoded in the +expression is correct. If it's overly difficult to describe a waiter for +a particular use-case, consider if the API itself is overly complex and +needs to be simplified. + + +Name waiters after the resource and state +----------------------------------------- + +Waiters SHOULD be named after the resource name and desired state, for example +````. "StateName" SHOULD match the expected state +name of the resource where possible. For example, if a "Snapshot" resource +can enter a "deleted" state, then the waiter name should be +``SnapshotDeleted`` and not ``SnapshotRemoved``. + +Good + * ObjectExists + * ConversionTaskDeleted +Bad + The following examples are bad because they are named after the completion + of an operation rather than the state of the resource: + + * RunInstanceComplete + * TerminateInstanceComplete + + More appropriate names would be: + + * InstanceRunning + * InstanceTerminated + +.. note:: + + A common and acceptable exception to this rule are ``Exists`` + and ``NotExists`` waiters. + + +Do not model implicit acceptors +------------------------------- + +Implicit acceptors are unnecessary and can quickly become incomplete as new +resource states and errors are added. Waiters have 2 implicit +:ref:`acceptors `: + +* (Step 4) - If none of the acceptors are matched and an error was + encountered while calling the operation, then transition to the + ``failure`` state and stop waiting. +* (Step 5) - Transition the waiter to the ``retry`` state, follow the + process described in :ref:`waiter-retries`, and continue to step 1. + +This means it is unnecessary to model an acceptor with an "errorType" +:ref:`matcher ` that transitions to a state of "failure". +This is already the default behavior. For example, the following acceptor +is unnecessary: + +.. code-block:: smithy + + { + acceptors: [ + { + state: "failure", + matcher: { + errorType: "ValidationError" + } + }, + // other acceptors... + ] + } + +Because a successful request that does not match any acceptor by default +transitions to the :ref:`retry state `, there is no +need to model matchers with a state of retry unless the matcher is for +specific errors. For example, the following matcher is unnecessary: + +.. code-block:: smithy + + { + acceptors: [ + { + state: "retry", + matcher: { + success: true + } + }, + // other acceptors... + ] + } + + +Only model terminal failure states +---------------------------------- + +Waiters SHOULD only model terminal failure states. A *terminal failure state* +is a resource state in which the resource cannot transition to the desired +success state without a user taking some explicit action. Only modeling +terminal failure states keeps waiter configurations as minimal as possible, +and it allows for more flexibility in the future. By avoiding the use of +intermediate resource states for waiter failure state transitions, a service +can add other intermediate states in the future without affecting existing +waiter logic. + +For example, suppose a resource has the following state transitions, and +if a resource is in the "Stopped" state, it can only transition to "Running" +if the user invokes the "StartResource" API operation: + +.. text-figure:: + :caption: **Figure Waiters-1.1**: Example resource state transitions + :name: waiters-figure-1.1 + + User calls + StopResource + ┌──────────┐ ┌──────────┐ ┌──────────┐ + │ Creating │───────▶│ Stopping │───────▶│ Stopped │ + └──────────┘ └──────────┘ └──────────┘ + │ │ + │ │ User calls + │ │ StartResource + │ ▼ + │ ┌──────────┐ + └────────────────────────────────▶│ Starting │ + └──────────┘ + │ + │ + │ + ▼ + ┌──────────┐ + │ Running │ + └──────────┘ + +A "ResourceRunning" waiter for the above resource SHOULD NOT include +the intermediate state transition "Stopping" to fail-fast. Instead, a failure +transition should be defined that matches on the terminal "Stopped" state +because the only way to transition from "Stopped" to running is by invoking +the ``StartResource`` API operation. + +.. code-block:: smithy + + @waitable( + ResourceRunning: { + description: "Waits for the resource to be running", + acceptors: [ + { + state: "failure", + matcher: { + output: { + path: "State", + expected: "Stopped", + comparator: "stringEquals" + } + } + }, + { + state: "success", + matcher: { + output: { + path: "State", + expected: "Running", + comparator: "stringEquals" + } + } + }, + // other acceptors... + ] + } + ) + operation GetResource { + input: GetResourceInput, + output: GetResourceOutput, + } + + +.. _CommonMark: https://spec.commonmark.org/ +.. _JMESPath: https://jmespath.org/ +.. _JMESPath types: https://jmespath.org/specification.html#data-types +.. _exponential backoff: https://aws.amazon.com/builders-library/timeouts-retries-and-backoff-with-jitter/ diff --git a/docs/source/conf.py b/docs/source/conf.py index dc52b2bcaae..a63e2743461 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -28,6 +28,7 @@ extensions = ['sphinx_tabs.tabs', # We use redirects to be able to change page names. 'sphinxcontrib.redirects', + 'sphinx.ext.imgmath', 'smithy'] # Add any paths that contain templates here, relative to this directory. diff --git a/settings.gradle b/settings.gradle index ceeef54aaf9..3b36176d0b5 100644 --- a/settings.gradle +++ b/settings.gradle @@ -23,3 +23,4 @@ include ":smithy-openapi" include ":smithy-utils" include ":smithy-protocol-test-traits" include ':smithy-jmespath' +include ":smithy-waiters" diff --git a/smithy-waiters/build.gradle b/smithy-waiters/build.gradle new file mode 100644 index 00000000000..435a7408396 --- /dev/null +++ b/smithy-waiters/build.gradle @@ -0,0 +1,26 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +description = "Defines Smithy waiters." + +ext { + displayName = "Smithy :: Waiters" + moduleName = "software.amazon.smithy.waiters" +} + +dependencies { + api project(":smithy-model") + api project(":smithy-jmespath") +} diff --git a/smithy-waiters/src/main/java/software/amazon/smithy/waiters/Acceptor.java b/smithy-waiters/src/main/java/software/amazon/smithy/waiters/Acceptor.java new file mode 100644 index 00000000000..f6a2f93384c --- /dev/null +++ b/smithy-waiters/src/main/java/software/amazon/smithy/waiters/Acceptor.java @@ -0,0 +1,100 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.smithy.waiters; + +import java.util.Objects; +import java.util.Set; +import software.amazon.smithy.model.node.ExpectationNotMetException; +import software.amazon.smithy.model.node.Node; +import software.amazon.smithy.model.node.ObjectNode; +import software.amazon.smithy.model.node.ToNode; +import software.amazon.smithy.utils.SetUtils; + +/** + * Represents an acceptor in a waiter's state machine. + */ +public final class Acceptor implements ToNode { + + private static final String STATE = "state"; + private static final String MATCHER = "matcher"; + private static final Set KEYS = SetUtils.of(STATE, MATCHER); + + private final AcceptorState state; + private final Matcher matcher; + + /** + * @param state State the acceptor transitions to when matched. + * @param matcher The matcher to match against. + */ + public Acceptor(AcceptorState state, Matcher matcher) { + this.state = state; + this.matcher = matcher; + } + + /** + * Gets the state to transition to if matched. + * + * @return Acceptor state to transition to. + */ + public AcceptorState getState() { + return state; + } + + /** + * Gets the matcher used to test if the acceptor. + * + * @return Returns the matcher. + */ + public Matcher getMatcher() { + return matcher; + } + + /** + * Creates an Acceptor from a {@link Node}. + * + * @param node Node to create the Acceptor from. + * @return Returns the created Acceptor. + * @throws ExpectationNotMetException if the given Node is invalid. + */ + public static Acceptor fromNode(Node node) { + ObjectNode value = node.expectObjectNode().warnIfAdditionalProperties(KEYS); + return new Acceptor(AcceptorState.fromNode(value.expectStringMember(STATE)), + Matcher.fromNode(value.expectMember(MATCHER))); + } + + @Override + public Node toNode() { + return Node.objectNode() + .withMember("state", Node.from(state.toString())) + .withMember("matcher", matcher.toNode()); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } else if (!(o instanceof Acceptor)) { + return false; + } + Acceptor acceptor = (Acceptor) o; + return getState() == acceptor.getState() && Objects.equals(getMatcher(), acceptor.getMatcher()); + } + + @Override + public int hashCode() { + return Objects.hash(getState(), getMatcher()); + } +} diff --git a/smithy-waiters/src/main/java/software/amazon/smithy/waiters/AcceptorState.java b/smithy-waiters/src/main/java/software/amazon/smithy/waiters/AcceptorState.java new file mode 100644 index 00000000000..9ad29d40186 --- /dev/null +++ b/smithy-waiters/src/main/java/software/amazon/smithy/waiters/AcceptorState.java @@ -0,0 +1,60 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.smithy.waiters; + +import java.util.Locale; +import software.amazon.smithy.model.node.ExpectationNotMetException; +import software.amazon.smithy.model.node.Node; +import software.amazon.smithy.model.node.StringNode; +import software.amazon.smithy.model.node.ToNode; + +/** + * The transition state of a waiter. + */ +public enum AcceptorState implements ToNode { + + /** Transition to a final success state. */ + SUCCESS, + + /** Transition to a final failure state. */ + FAILURE, + + /** Transition to a final retry state. */ + RETRY; + + @Override + public String toString() { + return super.toString().toLowerCase(Locale.ENGLISH); + } + + @Override + public Node toNode() { + return Node.from(toString()); + } + + /** + * Create an AcceptorState from a Node. + * + * @param node Node to create the AcceptorState from. + * @return Returns the created AcceptorState. + * @throws ExpectationNotMetException when given an invalid Node. + */ + public static AcceptorState fromNode(Node node) { + StringNode value = node.expectStringNode(); + String constValue = value.expectOneOf("success", "failure", "retry").toUpperCase(Locale.ENGLISH); + return AcceptorState.valueOf(constValue); + } +} diff --git a/smithy-waiters/src/main/java/software/amazon/smithy/waiters/Matcher.java b/smithy-waiters/src/main/java/software/amazon/smithy/waiters/Matcher.java new file mode 100644 index 00000000000..bb86ce37488 --- /dev/null +++ b/smithy-waiters/src/main/java/software/amazon/smithy/waiters/Matcher.java @@ -0,0 +1,374 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.smithy.waiters; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; +import software.amazon.smithy.model.node.ArrayNode; +import software.amazon.smithy.model.node.ExpectationNotMetException; +import software.amazon.smithy.model.node.Node; +import software.amazon.smithy.model.node.ObjectNode; +import software.amazon.smithy.model.node.StringNode; +import software.amazon.smithy.model.node.ToNode; + +/** + * Determines if an acceptor matches the current state of a resource. + */ +public abstract class Matcher implements ToNode { + + // A sealed constructor. + private Matcher() {} + + /** + * Visits the variants of the Matcher union type. + * + * @param Type of value to return from the visitor. + */ + public interface Visitor { + T visitOutput(OutputMember outputPath); + + T visitInput(InputMember inputPath); + + T visitSuccess(SuccessMember success); + + T visitErrorType(ErrorTypeMember errorType); + + T visitAnd(AndMember and); + + T visitOr(OrMember or); + + T visitNot(NotMember not); + + T visitUnknown(UnknownMember unknown); + } + + /** + * Gets the value of the set matcher variant. + * + * @return Returns the set variant's value. + */ + public abstract T getValue(); + + /** + * Gets the member name of the matcher. + * + * @return Returns the set member name. + */ + public abstract String getMemberName(); + + /** + * Visits the Matcher union type. + * + * @param visitor Visitor to apply. + * @param The type returned by the visitor. + * @return Returns the return value of the visitor. + */ + public abstract U accept(Visitor visitor); + + @Override + public final int hashCode() { + return Objects.hash(getMemberName(), getValue()); + } + + @Override + public final boolean equals(Object o) { + if (o == this) { + return true; + } else if (!(o instanceof Matcher)) { + return false; + } else { + Matcher other = (Matcher) o; + return getMemberName().equals(other.getMemberName()) && getValue().equals(other.getValue()); + } + } + + /** + * Creates a {@code Matcher} from a {@link Node}. + * + * @param node {@code Node} to create a {@code Matcher} from. + * @return Returns the create {@code Matcher}. + * @throws ExpectationNotMetException if the given {@code node} is invalid. + */ + public static Matcher fromNode(Node node) { + ObjectNode value = node.expectObjectNode(); + if (value.size() != 1) { + throw new ExpectationNotMetException("Union value must have exactly one value set", node); + } + + Map.Entry entry = value.getMembers().entrySet().iterator().next(); + String entryKey = entry.getKey().getValue(); + Node entryValue = entry.getValue(); + + switch (entryKey) { + case "input": + return new InputMember(PathMatcher.fromNode(entryValue)); + case "output": + return new OutputMember(PathMatcher.fromNode(entryValue)); + case "success": + return new SuccessMember(entryValue.expectBooleanNode().getValue()); + case "errorType": + return new ErrorTypeMember(entryValue.expectStringNode().getValue()); + case "and": + return MatcherList.fromNode(entryValue, AndMember::new); + case "or": + return MatcherList.fromNode(entryValue, OrMember::new); + case "not": + return new NotMember(fromNode(entryValue)); + default: + return new UnknownMember(entryKey, entryValue); + } + } + + private abstract static class PathMatcherMember extends Matcher { + private final String memberName; + private final PathMatcher value; + + private PathMatcherMember(String memberName, PathMatcher value) { + this.memberName = memberName; + this.value = value; + } + + @Override + public final String getMemberName() { + return memberName; + } + + @Override + public final PathMatcher getValue() { + return value; + } + + @Override + public final Node toNode() { + return Node.objectNode().withMember(getMemberName(), value.toNode()); + } + } + + public static final class OutputMember extends PathMatcherMember { + public OutputMember(PathMatcher value) { + super("output", value); + } + + @Override + public U accept(Visitor visitor) { + return visitor.visitOutput(this); + } + } + + public static final class InputMember extends PathMatcherMember { + public InputMember(PathMatcher value) { + super("input", value); + } + + @Override + public U accept(Visitor visitor) { + return visitor.visitInput(this); + } + } + + /** + * Matches if an operation returns an error, and the error matches the + * expected error type. + */ + public static final class ErrorTypeMember extends Matcher { + private final String value; + + public ErrorTypeMember(String value) { + this.value = value; + } + + @Override + public String getMemberName() { + return "errorType"; + } + + @Override + public String getValue() { + return value; + } + + @Override + public Node toNode() { + return Node.objectNode().withMember(getMemberName(), Node.from(value)); + } + + @Override + public U accept(Visitor visitor) { + return visitor.visitErrorType(this); + } + } + + /** + * When set to true, matches when a call returns a success response. + * When set to false, matches when a call fails with any error. + */ + public static final class SuccessMember extends Matcher { + private final boolean value; + + public SuccessMember(boolean value) { + this.value = value; + } + + @Override + public String getMemberName() { + return "success"; + } + + @Override + public Boolean getValue() { + return value; + } + + @Override + public Node toNode() { + return Node.objectNode().withMember(getMemberName(), Node.from(value)); + } + + @Override + public U accept(Visitor visitor) { + return visitor.visitSuccess(this); + } + } + + /** + * Represents an union union value. + */ + public static final class UnknownMember extends Matcher { + private final String key; + private final Node value; + + public UnknownMember(String key, Node value) { + this.key = key; + this.value = value; + } + + @Override + public String getMemberName() { + return key; + } + + @Override + public Node getValue() { + return value; + } + + @Override + public Node toNode() { + return Node.objectNode().withMember(getMemberName(), getValue()); + } + + @Override + public U accept(Visitor visitor) { + return visitor.visitUnknown(this); + } + } + + private abstract static class MatcherList extends Matcher>> { + private final List> values; + private final String memberName; + + private MatcherList(String memberName, List> values) { + this.memberName = memberName; + this.values = values; + } + + private static T fromNode(Node node, Function>, T> constructor) { + ArrayNode values = node.expectArrayNode(); + List> result = new ArrayList<>(); + for (ObjectNode element : values.getElementsAs(ObjectNode.class)) { + result.add(Matcher.fromNode(element)); + } + return constructor.apply(result); + } + + @Override + public String getMemberName() { + return memberName; + } + + public List> getValue() { + return values; + } + + @Override + public final Node toNode() { + return Node.objectNode() + .withMember(getMemberName(), values.stream().map(Matcher::toNode).collect(ArrayNode.collect())); + } + } + + /** + * Matches if all matchers in the list are matches. + */ + public static final class AndMember extends MatcherList { + public AndMember(List> matchers) { + super("and", matchers); + } + + @Override + public U accept(Visitor visitor) { + return visitor.visitAnd(this); + } + } + + /** + * Matches if any matchers in the list are matches. + */ + public static final class OrMember extends MatcherList { + public OrMember(List> matchers) { + super("or", matchers); + } + + @Override + public U accept(Visitor visitor) { + return visitor.visitOr(this); + } + } + + /** + * Matches if the given matcher is not a match. + */ + public static final class NotMember extends Matcher> { + private final Matcher value; + + public NotMember(Matcher value) { + this.value = value; + } + + @Override + public String getMemberName() { + return "not"; + } + + @Override + public Matcher getValue() { + return value; + } + + @Override + public Node toNode() { + return Node.objectNode().withMember(getMemberName(), getValue()); + } + + @Override + public U accept(Visitor visitor) { + return visitor.visitNot(this); + } + } +} diff --git a/smithy-waiters/src/main/java/software/amazon/smithy/waiters/ModelRuntimeTypeGenerator.java b/smithy-waiters/src/main/java/software/amazon/smithy/waiters/ModelRuntimeTypeGenerator.java new file mode 100644 index 00000000000..f0c6441ad8f --- /dev/null +++ b/smithy-waiters/src/main/java/software/amazon/smithy/waiters/ModelRuntimeTypeGenerator.java @@ -0,0 +1,271 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.smithy.waiters; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Supplier; +import software.amazon.smithy.jmespath.ast.LiteralExpression; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.shapes.BigDecimalShape; +import software.amazon.smithy.model.shapes.BigIntegerShape; +import software.amazon.smithy.model.shapes.BlobShape; +import software.amazon.smithy.model.shapes.BooleanShape; +import software.amazon.smithy.model.shapes.ByteShape; +import software.amazon.smithy.model.shapes.DocumentShape; +import software.amazon.smithy.model.shapes.DoubleShape; +import software.amazon.smithy.model.shapes.FloatShape; +import software.amazon.smithy.model.shapes.IntegerShape; +import software.amazon.smithy.model.shapes.ListShape; +import software.amazon.smithy.model.shapes.LongShape; +import software.amazon.smithy.model.shapes.MapShape; +import software.amazon.smithy.model.shapes.MemberShape; +import software.amazon.smithy.model.shapes.OperationShape; +import software.amazon.smithy.model.shapes.ResourceShape; +import software.amazon.smithy.model.shapes.ServiceShape; +import software.amazon.smithy.model.shapes.SetShape; +import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.model.shapes.ShapeVisitor; +import software.amazon.smithy.model.shapes.ShortShape; +import software.amazon.smithy.model.shapes.StringShape; +import software.amazon.smithy.model.shapes.StructureShape; +import software.amazon.smithy.model.shapes.TimestampShape; +import software.amazon.smithy.model.shapes.UnionShape; +import software.amazon.smithy.model.traits.LengthTrait; +import software.amazon.smithy.model.traits.RangeTrait; + +/** + * Generates fake data from a modeled shape for static JMESPath analysis. + */ +final class ModelRuntimeTypeGenerator implements ShapeVisitor { + + private final Model model; + private Set visited = new HashSet<>(); + + ModelRuntimeTypeGenerator(Model model) { + this.model = model; + } + + @Override + public Object blobShape(BlobShape shape) { + return "blob"; + } + + @Override + public Object booleanShape(BooleanShape shape) { + return true; + } + + @Override + public Object byteShape(ByteShape shape) { + return computeRange(shape); + } + + @Override + public Object shortShape(ShortShape shape) { + return computeRange(shape); + } + + @Override + public Object integerShape(IntegerShape shape) { + return computeRange(shape); + } + + @Override + public Object longShape(LongShape shape) { + return computeRange(shape); + } + + @Override + public Object floatShape(FloatShape shape) { + return computeRange(shape); + } + + @Override + public Object doubleShape(DoubleShape shape) { + return computeRange(shape); + } + + @Override + public Object bigIntegerShape(BigIntegerShape shape) { + return computeRange(shape); + } + + @Override + public Object bigDecimalShape(BigDecimalShape shape) { + return computeRange(shape); + } + + @Override + public Object documentShape(DocumentShape shape) { + return LiteralExpression.ANY; + } + + @Override + public Object stringShape(StringShape shape) { + // Create a random string that does not exceed or go under the length trait. + int chars = computeLength(shape); + + // Fill a string with "a"'s up to chars. + return new String(new char[chars]).replace("\0", "a"); + } + + @Override + public Object listShape(ListShape shape) { + return createListOrSet(shape, shape.getMember()); + } + + @Override + public Object setShape(SetShape shape) { + return createListOrSet(shape, shape.getMember()); + } + + private Object createListOrSet(Shape shape, MemberShape member) { + return withCopiedVisitors(() -> { + int size = computeLength(shape); + List result = new ArrayList<>(size); + Object memberValue = member.accept(this); + if (memberValue != null) { + for (int i = 0; i < size; i++) { + result.add(memberValue); + } + } + return result; + }); + } + + // Visits members and mutates a copy of the current set of + // visited shapes rather than a shared set. This a shape to + // be used multiple times in the closure of a single shape + // without causing the reuse of the shape to always be + // assume to be a recursive type. + private Object withCopiedVisitors(Supplier supplier) { + // Account for recursive shapes at the current + Set visitedCopy = new HashSet<>(visited); + Object result = supplier.get(); + visited = visitedCopy; + return result; + } + + @Override + public Object mapShape(MapShape shape) { + return withCopiedVisitors(() -> { + int size = computeLength(shape); + Map result = new HashMap<>(); + String key = (String) shape.getKey().accept(this); + Object memberValue = shape.getValue().accept(this); + for (int i = 0; i < size; i++) { + result.put(key + i, memberValue); + } + return result; + }); + } + + @Override + public Object structureShape(StructureShape shape) { + return structureOrUnion(shape); + } + + @Override + public Object unionShape(UnionShape shape) { + return structureOrUnion(shape); + } + + private Object structureOrUnion(Shape shape) { + return withCopiedVisitors(() -> { + Map result = new LinkedHashMap<>(); + for (MemberShape member : shape.members()) { + Object memberValue = member.accept(this); + result.put(member.getMemberName(), memberValue); + } + return result; + }); + } + + @Override + public Object memberShape(MemberShape shape) { + // Account for recursive shapes. + // A false return value means it was in the set. + if (!visited.add(shape)) { + return LiteralExpression.ANY; + } + + return model.getShape(shape.getTarget()) + .map(target -> target.accept(this)) + // Rather than fail on broken models during waiter validation, + // return an ANY to get *some* validation. + .orElse(LiteralExpression.ANY); + } + + @Override + public Object timestampShape(TimestampShape shape) { + return LiteralExpression.NUMBER; + } + + @Override + public Object operationShape(OperationShape shape) { + throw new UnsupportedOperationException(shape.toString()); + } + + @Override + public Object resourceShape(ResourceShape shape) { + throw new UnsupportedOperationException(shape.toString()); + } + + @Override + public Object serviceShape(ServiceShape shape) { + throw new UnsupportedOperationException(shape.toString()); + } + + private int computeLength(Shape shape) { + // Create a random string that does not exceed or go under the length trait. + int chars = 2; + + if (shape.hasTrait(LengthTrait.class)) { + LengthTrait trait = shape.expectTrait(LengthTrait.class); + if (trait.getMin().isPresent()) { + chars = Math.max(chars, trait.getMin().get().intValue()); + } + if (trait.getMax().isPresent()) { + chars = Math.min(chars, trait.getMax().get().intValue()); + } + } + + return chars; + } + + private double computeRange(Shape shape) { + // Create a random string that does not exceed or go under the length trait. + double i = 8; + + if (shape.hasTrait(RangeTrait.class)) { + RangeTrait trait = shape.expectTrait(RangeTrait.class); + if (trait.getMin().isPresent()) { + i = Math.max(i, trait.getMin().get().doubleValue()); + } + if (trait.getMax().isPresent()) { + i = Math.min(i, trait.getMax().get().doubleValue()); + } + } + + return i; + } +} diff --git a/smithy-waiters/src/main/java/software/amazon/smithy/waiters/PathComparator.java b/smithy-waiters/src/main/java/software/amazon/smithy/waiters/PathComparator.java new file mode 100644 index 00000000000..de431a1ba18 --- /dev/null +++ b/smithy-waiters/src/main/java/software/amazon/smithy/waiters/PathComparator.java @@ -0,0 +1,74 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.smithy.waiters; + +import software.amazon.smithy.model.node.ExpectationNotMetException; +import software.amazon.smithy.model.node.Node; +import software.amazon.smithy.model.node.ToNode; + +/** + * Defines a comparison to perform in a ListPathMatcher. + */ +public enum PathComparator implements ToNode { + + /** Matches if all values in the list matches the expected string. */ + ALL_STRING_EQUALS("allStringEquals"), + + /** Matches if any value in the list matches the expected string. */ + ANY_STRING_EQUALS("anyStringEquals"), + + /** Matches if the list is null or empty. */ + ARRAY_EMPTY("arrayEmpty"), + + /** Matches if the return value is a string that is equal to the expected string. */ + STRING_EQUALS("stringEquals"), + + /** Matches if the return value is a boolean that is equal to the string literal 'true' or 'false'. */ + BOOLEAN_EQUALS("booleanEquals"); + + private final String asString; + + PathComparator(String asString) { + this.asString = asString; + } + + /** + * Creates a {@code ListPathComparator} from a {@link Node}. + * @param node Node to create the {@code ListPathComparator} from. + * @return Returns the created {@code ListPathComparator}. + * @throws ExpectationNotMetException if the given {@code node} is invalid. + */ + public static PathComparator fromNode(Node node) { + String value = node.expectStringNode().getValue(); + for (PathComparator comparator : values()) { + if (comparator.toString().equals(value)) { + return comparator; + } + } + + throw new ExpectationNotMetException("Expected valid path comparator, but found " + value, node); + } + + @Override + public String toString() { + return asString; + } + + @Override + public Node toNode() { + return Node.from(toString()); + } +} diff --git a/smithy-waiters/src/main/java/software/amazon/smithy/waiters/PathMatcher.java b/smithy-waiters/src/main/java/software/amazon/smithy/waiters/PathMatcher.java new file mode 100644 index 00000000000..6c766169a5e --- /dev/null +++ b/smithy-waiters/src/main/java/software/amazon/smithy/waiters/PathMatcher.java @@ -0,0 +1,120 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.smithy.waiters; + +import java.util.Objects; +import java.util.Set; +import software.amazon.smithy.model.node.ExpectationNotMetException; +import software.amazon.smithy.model.node.Node; +import software.amazon.smithy.model.node.ObjectNode; +import software.amazon.smithy.model.node.ToNode; +import software.amazon.smithy.utils.SetUtils; + +/** + * A {@link Matcher} implementation for {@code inputPathList}, + * {@code outputPathList}, and {@code errorPathList}. + */ +public final class PathMatcher implements ToNode { + + private static final String EXPECTED = "expected"; + private static final String PATH = "path"; + private static final String COMPARATOR = "comparator"; + private static final Set KEYS = SetUtils.of(EXPECTED, PATH, COMPARATOR); + + private final String path; + private final String expected; + private final PathComparator comparator; + + /** + * @param path The path to execute. + * @param expected The expected value of the path. + * @param comparator Comparison performed on the list value. + */ + public PathMatcher(String path, String expected, PathComparator comparator) { + this.path = path; + this.expected = expected; + this.comparator = comparator; + } + + /** + * Gets the path to execute. + * + * @return Returns the path to execute. + */ + public String getPath() { + return path; + } + + /** + * Gets the expected return value of each element returned by the + * path. + * + * @return The return value to compare each result against. + */ + public String getExpected() { + return expected; + } + + /** + * Gets the comparison performed on the list. + * + * @return Returns the comparator. + */ + public PathComparator getComparator() { + return comparator; + } + + /** + * Creates a new instance from a {@link Node}. + * + * @param node Node tom create the ListPathMatcher from. + * @return Returns the created ListPathMatcher. + * @throws ExpectationNotMetException if the given Node is invalid. + */ + public static PathMatcher fromNode(Node node) { + ObjectNode value = node.expectObjectNode().warnIfAdditionalProperties(KEYS); + return new PathMatcher(value.expectStringMember(PATH).getValue(), + value.expectStringMember(EXPECTED).getValue(), + PathComparator.fromNode(value.expectStringMember(COMPARATOR))); + } + + @Override + public Node toNode() { + return Node.objectNode() + .withMember(PATH, Node.from(path)) + .withMember(EXPECTED, Node.from(expected)) + .withMember(COMPARATOR, comparator.toNode()); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } else if (!(o instanceof PathMatcher)) { + return false; + } + + PathMatcher that = (PathMatcher) o; + return getPath().equals(that.getPath()) + && getComparator().equals(that.getComparator()) + && getExpected().equals(that.getExpected()); + } + + @Override + public int hashCode() { + return Objects.hash(getPath(), getComparator(), getExpected()); + } +} diff --git a/smithy-waiters/src/main/java/software/amazon/smithy/waiters/WaitableTrait.java b/smithy-waiters/src/main/java/software/amazon/smithy/waiters/WaitableTrait.java new file mode 100644 index 00000000000..b79c9ebf7df --- /dev/null +++ b/smithy-waiters/src/main/java/software/amazon/smithy/waiters/WaitableTrait.java @@ -0,0 +1,118 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.smithy.waiters; + +import java.util.LinkedHashMap; +import java.util.Map; +import software.amazon.smithy.model.node.Node; +import software.amazon.smithy.model.node.ObjectNode; +import software.amazon.smithy.model.shapes.ShapeId; +import software.amazon.smithy.model.traits.AbstractTrait; +import software.amazon.smithy.model.traits.AbstractTraitBuilder; +import software.amazon.smithy.model.traits.Trait; +import software.amazon.smithy.model.traits.TraitService; +import software.amazon.smithy.utils.MapUtils; +import software.amazon.smithy.utils.SmithyBuilder; +import software.amazon.smithy.utils.ToSmithyBuilder; + +/** + * Indicates that an operation has various named "waiters" that can be used + * to poll a resource until it enters a desired state. + */ +public final class WaitableTrait extends AbstractTrait implements ToSmithyBuilder { + + public static final ShapeId ID = ShapeId.from("smithy.waiters#waitable"); + + private final Map waiters; + + private WaitableTrait(Builder builder) { + super(ID, builder.getSourceLocation()); + this.waiters = MapUtils.orderedCopyOf(builder.waiters); + } + + public static Builder builder() { + return new Builder(); + } + + @Override + public SmithyBuilder toBuilder() { + return new Builder().sourceLocation(getSourceLocation()).replace(waiters); + } + + /** + * Gets the waiters defined on the trait. + * + * @return Returns the defined waiters. + */ + public Map getWaiters() { + return waiters; + } + + @Override + protected Node createNode() { + ObjectNode.Builder builder = ObjectNode.objectNodeBuilder(); + builder.sourceLocation(getSourceLocation()); + for (Map.Entry entry : waiters.entrySet()) { + builder.withMember(entry.getKey(), entry.getValue().toNode()); + } + return builder.build(); + } + + public static final class Builder extends AbstractTraitBuilder { + + private final Map waiters = new LinkedHashMap<>(); + + private Builder() {} + + @Override + public WaitableTrait build() { + return new WaitableTrait(this); + } + + public Builder put(String name, Waiter value) { + waiters.put(name, value); + return this; + } + + public Builder clear() { + this.waiters.clear(); + return this; + } + + public Builder replace(Map waiters) { + clear(); + this.waiters.putAll(waiters); + return this; + } + } + + public static final class Provider implements TraitService { + @Override + public ShapeId getShapeId() { + return ID; + } + + @Override + public Trait createTrait(ShapeId target, Node value) { + ObjectNode node = value.expectObjectNode(); + Builder builder = builder().sourceLocation(value); + for (Map.Entry entry : node.getStringMap().entrySet()) { + builder.put(entry.getKey(), Waiter.fromNode(entry.getValue())); + } + return builder.build(); + } + } +} diff --git a/smithy-waiters/src/main/java/software/amazon/smithy/waiters/WaitableTraitValidator.java b/smithy-waiters/src/main/java/software/amazon/smithy/waiters/WaitableTraitValidator.java new file mode 100644 index 00000000000..d782dec9d86 --- /dev/null +++ b/smithy-waiters/src/main/java/software/amazon/smithy/waiters/WaitableTraitValidator.java @@ -0,0 +1,73 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.smithy.waiters; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.shapes.OperationShape; +import software.amazon.smithy.model.validation.AbstractValidator; +import software.amazon.smithy.model.validation.ValidationEvent; +import software.amazon.smithy.utils.SmithyInternalApi; + +@SmithyInternalApi +public final class WaitableTraitValidator extends AbstractValidator { + @Override + public List validate(Model model) { + return model.shapes(OperationShape.class) + .filter(operation -> operation.hasTrait(WaitableTrait.class)) + .flatMap(operation -> validateOperation(model, operation).stream()) + .collect(Collectors.toList()); + } + + private List validateOperation(Model model, OperationShape operation) { + List events = new ArrayList<>(); + WaitableTrait trait = operation.expectTrait(WaitableTrait.class); + + for (Map.Entry entry : trait.getWaiters().entrySet()) { + String waiterName = entry.getKey(); + Waiter waiter = entry.getValue(); + + if (waiter.getMinDelay() > waiter.getMaxDelay()) { + events.add(error(operation, trait, String.format( + "`%s` trait waiter named `%s` has a `minDelay` value of %d that is greater than its " + + "`maxDelay` value of %d", + WaitableTrait.ID, waiterName, waiter.getMinDelay(), waiter.getMaxDelay()))); + } + + boolean foundSuccess = false; + for (int i = 0; i < waiter.getAcceptors().size(); i++) { + Acceptor acceptor = waiter.getAcceptors().get(i); + WaiterMatcherValidator visitor = new WaiterMatcherValidator(model, operation, waiterName, i); + events.addAll(acceptor.getMatcher().accept(visitor)); + if (acceptor.getState() == AcceptorState.SUCCESS) { + foundSuccess = true; + } + } + + if (!foundSuccess) { + // Emitted as unsuppressable "WaitableTrait". + events.add(error(operation, trait, String.format( + "No success state matcher found for `%s` trait waiter named `%s`", + WaitableTrait.ID, waiterName))); + } + } + + return events; + } +} diff --git a/smithy-waiters/src/main/java/software/amazon/smithy/waiters/Waiter.java b/smithy-waiters/src/main/java/software/amazon/smithy/waiters/Waiter.java new file mode 100644 index 00000000000..3f5deea8456 --- /dev/null +++ b/smithy-waiters/src/main/java/software/amazon/smithy/waiters/Waiter.java @@ -0,0 +1,214 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.smithy.waiters; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import software.amazon.smithy.model.node.ArrayNode; +import software.amazon.smithy.model.node.ExpectationNotMetException; +import software.amazon.smithy.model.node.Node; +import software.amazon.smithy.model.node.NumberNode; +import software.amazon.smithy.model.node.ObjectNode; +import software.amazon.smithy.model.node.StringNode; +import software.amazon.smithy.model.node.ToNode; +import software.amazon.smithy.utils.ListUtils; +import software.amazon.smithy.utils.SetUtils; +import software.amazon.smithy.utils.SmithyBuilder; +import software.amazon.smithy.utils.ToSmithyBuilder; + +/** + * Defines an individual operation waiter. + */ +public final class Waiter implements ToNode, ToSmithyBuilder { + + private static final String DOCUMENTATION = "documentation"; + private static final String ACCEPTORS = "acceptors"; + private static final String MIN_DELAY = "minDelay"; + private static final String MAX_DELAY = "maxDelay"; + private static final int DEFAULT_MIN_DELAY = 2; + private static final int DEFAULT_MAX_DELAY = 120; + private static final Set KEYS = SetUtils.of(DOCUMENTATION, ACCEPTORS, MIN_DELAY, MAX_DELAY); + + private final String documentation; + private final List acceptors; + private final int minDelay; + private final int maxDelay; + + private Waiter(Builder builder) { + this.documentation = builder.documentation; + this.acceptors = ListUtils.copyOf(builder.acceptors); + this.minDelay = builder.minDelay; + this.maxDelay = builder.maxDelay; + } + + public static Builder builder() { + return new Builder(); + } + + @Override + public SmithyBuilder toBuilder() { + return builder() + .documentation(getDocumentation().orElse(null)) + .acceptors(getAcceptors()) + .minDelay(getMinDelay()) + .maxDelay(getMaxDelay()); + } + + /** + * Create a {@code Waiter} from a {@link Node}. + * + * @param node {@code Node} to create the {@code Waiter} from. + * @return Returns the created {@code Waiter}. + * @throws ExpectationNotMetException if the given {@code node} is invalid. + */ + public static Waiter fromNode(Node node) { + ObjectNode value = node.expectObjectNode().warnIfAdditionalProperties(KEYS); + Builder builder = builder(); + value.getStringMember(DOCUMENTATION).map(StringNode::getValue).ifPresent(builder::documentation); + for (Node entry : value.expectArrayMember(ACCEPTORS).getElements()) { + builder.addAcceptor(Acceptor.fromNode(entry)); + } + + value.getNumberMember(MIN_DELAY).map(NumberNode::getValue).map(Number::intValue).ifPresent(builder::minDelay); + value.getNumberMember(MAX_DELAY).map(NumberNode::getValue).map(Number::intValue).ifPresent(builder::maxDelay); + + return builder.build(); + } + + /** + * Gets the documentation of the waiter. + * + * @return Return the optional documentation. + */ + public Optional getDocumentation() { + return Optional.ofNullable(documentation); + } + + /** + * Gets the list of {@link Acceptor}s. + * + * @return Returns the acceptors of the waiter. + */ + public List getAcceptors() { + return acceptors; + } + + /** + * Gets the minimum amount of time to wait between retries + * in seconds. + * + * @return Gets the minimum retry wait time in seconds. + */ + public int getMinDelay() { + return minDelay; + } + + /** + * Gets the maximum amount of time allowed to wait between + * retries in seconds. + * + * @return Gets the maximum retry wait time in seconds. + */ + public int getMaxDelay() { + return maxDelay; + } + + @Override + public Node toNode() { + ObjectNode.Builder builder = Node.objectNodeBuilder() + .withOptionalMember(DOCUMENTATION, getDocumentation().map(Node::from)) + .withMember(ACCEPTORS, getAcceptors().stream().map(Acceptor::toNode).collect(ArrayNode.collect())); + + // Don't serialize default values for minDelay and maxDelay. + if (minDelay != DEFAULT_MIN_DELAY) { + builder.withMember(MIN_DELAY, minDelay); + } + if (maxDelay != DEFAULT_MAX_DELAY) { + builder.withMember(MAX_DELAY, maxDelay); + } + + return builder.build(); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } else if (!(o instanceof Waiter)) { + return false; + } + + Waiter waiter = (Waiter) o; + return minDelay == waiter.minDelay + && maxDelay == waiter.maxDelay + && Objects.equals(documentation, waiter.documentation) + && acceptors.equals(waiter.acceptors); + } + + @Override + public int hashCode() { + return Objects.hash(documentation, acceptors, minDelay, maxDelay); + } + + public static final class Builder implements SmithyBuilder { + + private String documentation; + private final List acceptors = new ArrayList<>(); + private int minDelay = DEFAULT_MIN_DELAY; + private int maxDelay = DEFAULT_MAX_DELAY; + + private Builder() {} + + @Override + public Waiter build() { + return new Waiter(this); + } + + public Builder documentation(String documentation) { + this.documentation = documentation; + return this; + } + + public Builder clearAcceptors() { + this.acceptors.clear(); + return this; + } + + public Builder acceptors(List acceptors) { + clearAcceptors(); + acceptors.forEach(this::addAcceptor); + return this; + } + + public Builder addAcceptor(Acceptor acceptor) { + this.acceptors.add(Objects.requireNonNull(acceptor)); + return this; + } + + public Builder minDelay(int minDelay) { + this.minDelay = minDelay; + return this; + } + + public Builder maxDelay(int maxDelay) { + this.maxDelay = maxDelay; + return this; + } + } +} diff --git a/smithy-waiters/src/main/java/software/amazon/smithy/waiters/WaiterMatcherValidator.java b/smithy-waiters/src/main/java/software/amazon/smithy/waiters/WaiterMatcherValidator.java new file mode 100644 index 00000000000..2ed048ec502 --- /dev/null +++ b/smithy-waiters/src/main/java/software/amazon/smithy/waiters/WaiterMatcherValidator.java @@ -0,0 +1,213 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.smithy.waiters; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import software.amazon.smithy.jmespath.ExpressionProblem; +import software.amazon.smithy.jmespath.JmespathException; +import software.amazon.smithy.jmespath.JmespathExpression; +import software.amazon.smithy.jmespath.LinterResult; +import software.amazon.smithy.jmespath.RuntimeType; +import software.amazon.smithy.jmespath.ast.LiteralExpression; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.knowledge.OperationIndex; +import software.amazon.smithy.model.shapes.OperationShape; +import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.model.shapes.ShapeId; +import software.amazon.smithy.model.shapes.StructureShape; +import software.amazon.smithy.model.validation.Severity; +import software.amazon.smithy.model.validation.ValidationEvent; + +final class WaiterMatcherValidator implements Matcher.Visitor> { + + private static final String NON_SUPPRESSABLE_ERROR = "WaitableTrait"; + private static final String JMESPATH_PROBLEM = NON_SUPPRESSABLE_ERROR + "JmespathProblem"; + private static final String INVALID_ERROR_TYPE = NON_SUPPRESSABLE_ERROR + "InvalidErrorType"; + + private final Model model; + private final OperationShape operation; + private final String waiterName; + private final WaitableTrait waitable; + private final List events = new ArrayList<>(); + private final int acceptorIndex; + + WaiterMatcherValidator(Model model, OperationShape operation, String waiterName, int acceptorIndex) { + this.model = Objects.requireNonNull(model); + this.operation = Objects.requireNonNull(operation); + this.waitable = operation.expectTrait(WaitableTrait.class); + this.waiterName = Objects.requireNonNull(waiterName); + this.acceptorIndex = acceptorIndex; + } + + @Override + public List visitOutput(Matcher.OutputMember outputPath) { + StructureShape struct = OperationIndex.of(model).getOutput(operation).orElse(null); + if (struct == null) { + addEvent(Severity.ERROR, NON_SUPPRESSABLE_ERROR, "output path used on operation with no output"); + } else { + validatePathMatcher(struct, outputPath.getValue()); + } + return events; + } + + @Override + public List visitInput(Matcher.InputMember inputPath) { + StructureShape struct = OperationIndex.of(model).getInput(operation).orElse(null); + if (struct == null) { + addEvent(Severity.ERROR, NON_SUPPRESSABLE_ERROR, "input path used on operation with no input"); + } else { + validatePathMatcher(struct, inputPath.getValue()); + } + return events; + } + + @Override + public List visitSuccess(Matcher.SuccessMember success) { + return events; + } + + @Override + public List visitErrorType(Matcher.ErrorTypeMember errorType) { + // Ensure that the errorType is defined on the operation. There may be cases + // where the errorType is framework based or lower level, so it might not be + // defined in the actual model. + String error = errorType.getValue(); + + for (ShapeId errorId : operation.getErrors()) { + if (error.equals(errorId.toString()) || error.equals(errorId.getName())) { + return events; + } + } + + addEvent(Severity.WARNING, INVALID_ERROR_TYPE, String.format( + "errorType '%s' not found on operation. This operation defines the following errors: %s", + error, operation.getErrors())); + + return events; + } + + @Override + public List visitAnd(Matcher.AndMember and) { + for (Matcher matcher : and.getValue()) { + matcher.accept(this); + } + return events; + } + + @Override + public List visitOr(Matcher.OrMember or) { + for (Matcher matcher : or.getValue()) { + matcher.accept(this); + } + return events; + } + + @Override + public List visitNot(Matcher.NotMember not) { + not.getValue().accept(this); + return events; + } + + @Override + public List visitUnknown(Matcher.UnknownMember unknown) { + // This is validated by model validation. No need to do more here. + return events; + } + + private void validatePathMatcher(StructureShape struct, PathMatcher pathMatcher) { + RuntimeType returnType = validatePath(struct, pathMatcher.getPath()); + + switch (pathMatcher.getComparator()) { + case BOOLEAN_EQUALS: + // A booleanEquals comparator requires an `expected` value of "true" or "false". + if (!pathMatcher.getExpected().equals("true") && !pathMatcher.getExpected().equals("false")) { + addEvent(Severity.ERROR, NON_SUPPRESSABLE_ERROR, String.format( + "Waiter acceptors with a %s comparator must set their `expected` value to 'true' or " + + "'false', but found '%s'.", + PathComparator.BOOLEAN_EQUALS, pathMatcher.getExpected())); + } + validateReturnType(pathMatcher.getComparator(), RuntimeType.BOOLEAN, returnType); + break; + case STRING_EQUALS: + validateReturnType(pathMatcher.getComparator(), RuntimeType.STRING, returnType); + break; + default: // array operations + validateReturnType(pathMatcher.getComparator(), RuntimeType.ARRAY, returnType); + } + } + + private void validateReturnType(PathComparator comparator, RuntimeType expected, RuntimeType actual) { + if (actual != RuntimeType.ANY && actual != expected) { + addEvent(Severity.DANGER, JMESPATH_PROBLEM, String.format( + "Waiter acceptors with a %s comparator must return a `%s` type, but this acceptor was " + + "statically determined to return a `%s` type.", + comparator, expected, actual)); + } + } + + private RuntimeType validatePath(StructureShape struct, String path) { + try { + JmespathExpression expression = JmespathExpression.parse(path); + LinterResult result = expression.lint(createCurrentNodeFromShape(struct)); + for (ExpressionProblem problem : result.getProblems()) { + addJmespathEvent(path, problem); + } + return result.getReturnType(); + } catch (JmespathException e) { + addEvent(Severity.ERROR, NON_SUPPRESSABLE_ERROR, String.format( + "Invalid JMESPath expression (%s): %s", path, e.getMessage())); + return RuntimeType.ANY; + } + } + + // Lint using an ANY type or using the modeled shape as the starting data. + private LiteralExpression createCurrentNodeFromShape(Shape shape) { + return shape == null + ? LiteralExpression.ANY + : new LiteralExpression(shape.accept(new ModelRuntimeTypeGenerator(model))); + } + + private void addJmespathEvent(String path, ExpressionProblem problem) { + Severity severity; + switch (problem.severity) { + case ERROR: + severity = Severity.ERROR; + break; + case DANGER: + severity = Severity.DANGER; + break; + default: + severity = Severity.WARNING; + break; + } + + String problemMessage = problem.message + " (" + problem.line + ":" + problem.column + ")"; + addEvent(severity, severity == Severity.ERROR ? NON_SUPPRESSABLE_ERROR : JMESPATH_PROBLEM, String.format( + "Problem found in JMESPath expression (%s): %s", path, problemMessage)); + } + + private void addEvent(Severity severity, String id, String message) { + events.add(ValidationEvent.builder() + .id(id) + .shape(operation) + .sourceLocation(waitable) + .severity(severity) + .message(String.format("Waiter `%s`, acceptor %d: %s", waiterName, acceptorIndex, message)) + .build()); + } +} diff --git a/smithy-waiters/src/main/resources/META-INF/services/software.amazon.smithy.model.traits.TraitService b/smithy-waiters/src/main/resources/META-INF/services/software.amazon.smithy.model.traits.TraitService new file mode 100644 index 00000000000..c9cf671cf0a --- /dev/null +++ b/smithy-waiters/src/main/resources/META-INF/services/software.amazon.smithy.model.traits.TraitService @@ -0,0 +1 @@ +software.amazon.smithy.waiters.WaitableTrait$Provider diff --git a/smithy-waiters/src/main/resources/META-INF/services/software.amazon.smithy.model.validation.Validator b/smithy-waiters/src/main/resources/META-INF/services/software.amazon.smithy.model.validation.Validator new file mode 100644 index 00000000000..6a6953d8013 --- /dev/null +++ b/smithy-waiters/src/main/resources/META-INF/services/software.amazon.smithy.model.validation.Validator @@ -0,0 +1 @@ +software.amazon.smithy.waiters.WaitableTraitValidator diff --git a/smithy-waiters/src/main/resources/META-INF/smithy/manifest b/smithy-waiters/src/main/resources/META-INF/smithy/manifest new file mode 100644 index 00000000000..497a7c5ddfa --- /dev/null +++ b/smithy-waiters/src/main/resources/META-INF/smithy/manifest @@ -0,0 +1 @@ +waiters.smithy diff --git a/smithy-waiters/src/main/resources/META-INF/smithy/waiters.smithy b/smithy-waiters/src/main/resources/META-INF/smithy/waiters.smithy new file mode 100644 index 00000000000..5afd2473062 --- /dev/null +++ b/smithy-waiters/src/main/resources/META-INF/smithy/waiters.smithy @@ -0,0 +1,168 @@ +namespace smithy.waiters + +/// Indicates that an operation has various named "waiters" that can be used +/// to poll a resource until it enters a desired state. +@trait(selector: "operation :not(-[input, output]-> structure > member > union[trait|streaming])") +@length(min: 1) +map waitable { + key: WaiterName, + value: Waiter, +} + +@pattern("^[A-Z]+[A-Za-z0-9]*$") +string WaiterName + +/// Defines an individual operation waiter. +@private +structure Waiter { + /// Documentation about the waiter. Can use CommonMark. + documentation: String, + + /// An ordered array of acceptors to check after executing an operation. + @required + acceptors: Acceptors, + + /// The minimum amount of time in seconds to delay between each retry. + /// This value defaults to 2 if not specified. If specified, this value + /// MUST be greater than or equal to 1 and less than or equal to + /// `maxDelay`. + minDelay: WaiterDelay, + + /// The maximum amount of time in seconds to delay between each retry. + /// This value defaults to 256 if not specified (or, 4 minutes and 16 + /// seconds). If specified, this value MUST be greater than or equal + /// to 1. + maxDelay: WaiterDelay, +} + +@box +@range(min: 1) +integer WaiterDelay + +@private +@length(min: 1) +list Acceptors { + member: Acceptor +} + +/// Represents an acceptor in a waiter's state machine. +@private +structure Acceptor { + /// The state the acceptor transitions to when matched. + @required + state: AcceptorState, + + /// The matcher used to test if the resource is in a given state. + @required + matcher: Matcher, +} + +/// The transition state of a waiter. +@private +@enum([ + { + "name": "SUCCESS", + "value": "success", + "documentation": """ + The waiter successfully finished waiting. This is a terminal + state that causes the waiter to stop.""" + }, + { + "name": "FAILURE", + "value": "failure", + "documentation": """ + The waiter failed to enter into the desired state. This is a + terminal state that causes the waiter to stop.""" + }, + { + "name": "RETRY", + "value": "retry", + "documentation": """ + The waiter will retry the operation. This state transition is + implicit if no accepter causes a state transition.""" + }, +]) +string AcceptorState + +@private +union Matcher { + /// Matches on the input of an operation using a JMESPath expression. + input: PathMatcher, + + /// Matches on the successful output of an operation using a + /// JMESPath expression. + output: PathMatcher, + + /// Matches if an operation returns an error and the error matches + /// the expected error type. If an absolute shape ID is provided, the + /// error is matched exactly on the shape ID. A shape name can be + /// provided to match an error in any namespace with the given name. + errorType: String, + + /// When set to `true`, matches when an operation returns a successful + /// response. When set to `false`, matches when an operation fails with + /// any error. + success: Boolean, + + /// Matches if all matchers in the list match. + and: MatcherList, + + /// Matches if any matchers in the list match. + or: MatcherList, + + /// Matches if the given matcher is not a match. + not: Matcher, +} + +@private +structure PathMatcher { + /// A JMESPath expression applied to the input or output of an operation. + @required + path: String, + + /// The expected return value of the expression. + @required + expected: String, + + /// The comparator used to compare the result of the expression with the + /// expected value. + @required + comparator: PathComparator, +} + +/// Defines a comparison to perform in a ListPathMatcher. +@enum([ + { + "name": "STRING_EQUALS", + "value": "stringEquals", + "documentation": "Matches if the return value is a string that is equal to the expected string." + }, + { + "name": "BOOLEAN_EQUALS", + "value": "booleanEquals", + "documentation": "Matches if the return value is a boolean that is equal to the string literal 'true' or 'false'." + }, + { + "name": "ALL_STRING_EQUALS", + "value": "allStringEquals", + "documentation": "Matches if all values in the list matches the expected string." + }, + { + "name": "ANY_STRING_EQUALS", + "value": "anyStringEquals", + "documentation": "Matches if any value in the list matches the expected string." + }, + { + "name": "ARRAY_EMPTY", + "value": "arrayEmpty", + "documentation": "Matches if the return value is an array that is null or empty." + }, +]) +@private +string PathComparator + +@private +@length(min: 1) +list MatcherList { + member: Matcher, +} diff --git a/smithy-waiters/src/test/java/software/amazon/smithy/waiters/ModelRuntimeTypeGeneratorTest.java b/smithy-waiters/src/test/java/software/amazon/smithy/waiters/ModelRuntimeTypeGeneratorTest.java new file mode 100644 index 00000000000..dfe79915b3d --- /dev/null +++ b/smithy-waiters/src/test/java/software/amazon/smithy/waiters/ModelRuntimeTypeGeneratorTest.java @@ -0,0 +1,93 @@ +package software.amazon.smithy.waiters; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import software.amazon.smithy.jmespath.ast.LiteralExpression; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.model.shapes.ShapeId; + +public class ModelRuntimeTypeGeneratorTest { + + private static Model model; + + @BeforeAll + static void before() { + model = Model.assembler() + .addImport(ModelRuntimeTypeGenerator.class.getResource("model-runtime-types.smithy")) + .assemble() + .unwrap(); + } + + @ParameterizedTest + @MethodSource("shapeSource") + public void convertsShapeToExpectedValue(String shapeName, Object expected) { + ShapeId id = ShapeId.fromOptionalNamespace("smithy.example", shapeName); + Shape shape = model.expectShape(id); + ModelRuntimeTypeGenerator generator = new ModelRuntimeTypeGenerator(model); + Object actual = shape.accept(generator); + assertThat(expected, equalTo(actual)); + } + + public static Collection shapeSource() { + Map stringListMap = new LinkedHashMap<>(); + stringListMap.put("aa0", Arrays.asList("aa", "aa")); + stringListMap.put("aa1", Arrays.asList("aa", "aa")); + + Map sizedStringListMap = new LinkedHashMap<>(); + sizedStringListMap.put("aa0", Arrays.asList("aa", "aa")); + sizedStringListMap.put("aa1", Arrays.asList("aa", "aa")); + sizedStringListMap.put("aa2", Arrays.asList("aa", "aa")); + sizedStringListMap.put("aa3", Arrays.asList("aa", "aa")); + sizedStringListMap.put("aa4", Arrays.asList("aa", "aa")); + + Map myUnionOrStruct = new LinkedHashMap<>(); + myUnionOrStruct.put("foo", "aa"); + + Map recursiveStruct = new LinkedHashMap<>(); + recursiveStruct.put("foo", Arrays.asList("aa", "aa")); + Map recursiveStructAny = new LinkedHashMap<>(); + recursiveStructAny.put("foo", LiteralExpression.ANY); + recursiveStructAny.put("bar", LiteralExpression.ANY); + recursiveStruct.put("bar", Collections.singletonList(recursiveStructAny)); + + return Arrays.asList(new Object[][] { + {"StringList", Arrays.asList("aa", "aa")}, + {"SizedStringList", Arrays.asList("aa", "aa", "aa", "aa", "aa")}, + {"StringSet", Arrays.asList("aa", "aa")}, + {"SizedStringSet", Arrays.asList("aa", "aa", "aa", "aa", "aa")}, + {"StringListMap", stringListMap}, + {"SizedStringListMap", sizedStringListMap}, + {"SizedString1", "aaaa"}, + {"SizedString2", "a"}, + {"SizedString3", "aaaaaaaa"}, + {"SizedInteger1", 100.0}, + {"SizedInteger2", 2.0}, + {"SizedInteger3", 8.0}, + {"MyUnion", myUnionOrStruct}, + {"MyStruct", myUnionOrStruct}, + {"smithy.api#Blob", "blob"}, + {"smithy.api#Document", LiteralExpression.ANY}, + {"smithy.api#Boolean", true}, + {"smithy.api#Byte", 8.0}, + {"smithy.api#Short", 8.0}, + {"smithy.api#Integer", 8.0}, + {"smithy.api#Long", 8.0}, + {"smithy.api#Float", 8.0}, + {"smithy.api#Double", 8.0}, + {"smithy.api#BigInteger", 8.0}, + {"smithy.api#BigDecimal", 8.0}, + {"smithy.api#Timestamp", LiteralExpression.NUMBER}, + {"RecursiveStruct", recursiveStruct} + }); + } +} diff --git a/smithy-waiters/src/test/java/software/amazon/smithy/waiters/RunnerTest.java b/smithy-waiters/src/test/java/software/amazon/smithy/waiters/RunnerTest.java new file mode 100644 index 00000000000..b0483856744 --- /dev/null +++ b/smithy-waiters/src/test/java/software/amazon/smithy/waiters/RunnerTest.java @@ -0,0 +1,35 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.smithy.waiters; + +import java.util.concurrent.Callable; +import java.util.stream.Stream; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import software.amazon.smithy.model.validation.testrunner.SmithyTestCase; +import software.amazon.smithy.model.validation.testrunner.SmithyTestSuite; + +public class RunnerTest { + @ParameterizedTest(name = "{0}") + @MethodSource("source") + public void testRunner(String filename, Callable callable) throws Exception { + callable.call(); + } + + public static Stream source() { + return SmithyTestSuite.defaultParameterizedTestSource(RunnerTest.class); + } +} diff --git a/smithy-waiters/src/test/java/software/amazon/smithy/waiters/WaiterTest.java b/smithy-waiters/src/test/java/software/amazon/smithy/waiters/WaiterTest.java new file mode 100644 index 00000000000..03b331e5f49 --- /dev/null +++ b/smithy-waiters/src/test/java/software/amazon/smithy/waiters/WaiterTest.java @@ -0,0 +1,56 @@ +package software.amazon.smithy.waiters; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; + +import java.util.Optional; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.model.node.Node; +import software.amazon.smithy.model.node.ObjectNode; + +public class WaiterTest { + @Test + public void setsDefaultValuesForMinAndMaxDelay() { + Matcher matcher = new Matcher.SuccessMember(true); + Acceptor a1 = new Acceptor(AcceptorState.SUCCESS, matcher); + Waiter waiter = Waiter.builder().addAcceptor(a1).build(); + + assertThat(waiter.getMinDelay(), is(2)); + assertThat(waiter.getMaxDelay(), is(120)); + } + + @Test + public void doesNotIncludeDefaultValuesInNode() { + Matcher matcher = new Matcher.SuccessMember(true); + Acceptor a1 = new Acceptor(AcceptorState.SUCCESS, matcher); + Waiter waiter = Waiter.builder().addAcceptor(a1).build(); + ObjectNode node = waiter.toNode().expectObjectNode(); + + assertThat(node.getMember("minDelay"), equalTo(Optional.empty())); + assertThat(node.getMember("maxDelay"), equalTo(Optional.empty())); + + assertThat(waiter.toBuilder().build(), equalTo(waiter)); + assertThat(Waiter.fromNode(waiter.toNode()), equalTo(waiter)); + } + + @Test + public void includesMinDelayAndMaxDelayInNodeIfNotDefaults() { + Matcher matcher = new Matcher.SuccessMember(true); + Acceptor a1 = new Acceptor(AcceptorState.SUCCESS, matcher); + Waiter waiter = Waiter.builder() + .minDelay(10) + .maxDelay(100) + .addAcceptor(a1) + .build(); + ObjectNode node = waiter.toNode().expectObjectNode(); + + assertThat(waiter.getMinDelay(), is(10)); + assertThat(waiter.getMaxDelay(), is(100)); + assertThat(node.getMember("minDelay"), equalTo(Optional.of(Node.from(10)))); + assertThat(node.getMember("maxDelay"), equalTo(Optional.of(Node.from(100)))); + + assertThat(waiter.toBuilder().build(), equalTo(waiter)); + assertThat(Waiter.fromNode(waiter.toNode()), equalTo(waiter)); + } +} diff --git a/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/cannot-wait-on-streaming-operations.errors b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/cannot-wait-on-streaming-operations.errors new file mode 100644 index 00000000000..fb1127d4abd --- /dev/null +++ b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/cannot-wait-on-streaming-operations.errors @@ -0,0 +1,2 @@ +[ERROR] smithy.example#StreamingInput: Trait `smithy.waiters#waitable` cannot be applied to `smithy.example#StreamingInput` | TraitTarget +[ERROR] smithy.example#StreamingOutput: Trait `smithy.waiters#waitable` cannot be applied to `smithy.example#StreamingOutput` | TraitTarget diff --git a/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/cannot-wait-on-streaming-operations.smithy b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/cannot-wait-on-streaming-operations.smithy new file mode 100644 index 00000000000..a9032976c34 --- /dev/null +++ b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/cannot-wait-on-streaming-operations.smithy @@ -0,0 +1,44 @@ +namespace smithy.example + +use smithy.waiters#waitable + +@waitable( + Success: { + documentation: "A", + acceptors: [ + { + state: "success", + matcher: {success: true} + } + ] + } +) +operation StreamingInput { + input: StreamingInputOutput +} + +structure StreamingInputOutput { + messages: Messages, +} + +@streaming +union Messages { + success: SuccessMessage +} + +structure SuccessMessage {} + +@waitable( + Success: { + documentation: "B", + acceptors: [ + { + state: "success", + matcher: {success: true} + } + ] + } +) +operation StreamingOutput { + input: StreamingInputOutput +} diff --git a/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/emits-danger-and-warning-typechecks.errors b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/emits-danger-and-warning-typechecks.errors new file mode 100644 index 00000000000..218f8ee06a7 --- /dev/null +++ b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/emits-danger-and-warning-typechecks.errors @@ -0,0 +1,3 @@ +[DANGER] smithy.example#A: Waiter `Invalid1`, acceptor 0: Problem found in JMESPath expression (`10`.foo): Object field 'foo' extraction performed on number (1:6) | WaitableTraitJmespathProblem +[DANGER] smithy.example#A: Waiter `Invalid2`, acceptor 0: Waiter acceptors with a booleanEquals comparator must return a `boolean` type, but this acceptor was statically determined to return a `null` type. | WaitableTraitJmespathProblem +[WARNING] smithy.example#A: Waiter `Invalid2`, acceptor 0: Problem found in JMESPath expression (`true` < `false`): Invalid comparator '<' for boolean (1:10) | WaitableTraitJmespathProblem diff --git a/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/emits-danger-and-warning-typechecks.smithy b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/emits-danger-and-warning-typechecks.smithy new file mode 100644 index 00000000000..92bcbe73c89 --- /dev/null +++ b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/emits-danger-and-warning-typechecks.smithy @@ -0,0 +1,42 @@ +namespace smithy.example + +use smithy.waiters#waitable + +@waitable( + Invalid1: { + "documentation": "A", + "acceptors": [ + { + "state": "success", + "matcher": { + "output": { + "path": "`10`.foo", // can't select a field from a literal. + "comparator": "booleanEquals", + "expected": "true" + } + } + } + ] + }, + Invalid2: { + "acceptors": [ + { + "state": "success", + "matcher": { + "output": { + "path": "`true` < `false`", // can't compare these + "comparator": "booleanEquals", + "expected": "true" + } + } + } + ] + } +) +operation A { + output: AOutput +} + +structure AOutput { + foo: String, +} diff --git a/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/input-output-on-bad-shapes.errors b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/input-output-on-bad-shapes.errors new file mode 100644 index 00000000000..fd96f55eab8 --- /dev/null +++ b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/input-output-on-bad-shapes.errors @@ -0,0 +1,2 @@ +[ERROR] smithy.example#A: Waiter `A`, acceptor 0: input path used on operation with no input | WaitableTrait +[ERROR] smithy.example#A: Waiter `B`, acceptor 0: output path used on operation with no output | WaitableTrait diff --git a/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/input-output-on-bad-shapes.smithy b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/input-output-on-bad-shapes.smithy new file mode 100644 index 00000000000..cdb8edd7b01 --- /dev/null +++ b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/input-output-on-bad-shapes.smithy @@ -0,0 +1,36 @@ +namespace smithy.example + +use smithy.waiters#waitable + +@waitable( + A: { + "documentation": "A", + "acceptors": [ + { + "state": "success", + "matcher": { + "input": { + "path": "foo == 'hi'", + "comparator": "booleanEquals", + "expected": "true" + } + } + } + ] + }, + B: { + "acceptors": [ + { + "state": "success", + "matcher": { + "output": { + "path": "foo == 'hey'", + "comparator": "booleanEquals", + "expected": "true" + } + } + } + ] + } +) +operation A {} diff --git a/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-boolean-expected-value.errors b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-boolean-expected-value.errors new file mode 100644 index 00000000000..26988cbc186 --- /dev/null +++ b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-boolean-expected-value.errors @@ -0,0 +1 @@ +[ERROR] smithy.example#A: Waiter `A`, acceptor 0: Waiter acceptors with a booleanEquals comparator must set their `expected` value to 'true' or 'false', but found 'foo'. | WaitableTrait diff --git a/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-boolean-expected-value.smithy b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-boolean-expected-value.smithy new file mode 100644 index 00000000000..1421eaa42b0 --- /dev/null +++ b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-boolean-expected-value.smithy @@ -0,0 +1,52 @@ +namespace smithy.example + +use smithy.waiters#waitable + +@waitable( + A: { + "documentation": "A", + "acceptors": [ + { + "state": "success", + "matcher": { + "output": { + "path": "`true`", + "comparator": "booleanEquals", + "expected": "foo" // must be true | false + } + } + }, + { + "state": "retry", + "matcher": { + "output": { + "path": "`true`", + "comparator": "booleanEquals", + "expected": "true" // this is fine + } + } + }, + { + "state": "failure", + "matcher": { + "output": { + "path": "`true`", + "comparator": "booleanEquals", + "expected": "false" // this is fine + } + } + }, + ] + } +) +operation A { + output: AOutput, + errors: [OhNo], +} + +structure AOutput { + foo: String, +} + +@error("client") +structure OhNo {} diff --git a/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-errorType.errors b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-errorType.errors new file mode 100644 index 00000000000..8e64d042b84 --- /dev/null +++ b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-errorType.errors @@ -0,0 +1 @@ +[WARNING] smithy.example#A: Waiter `A`, acceptor 0: errorType 'Nope' not found on operation. This operation defines the following errors: [smithy.example#OhNo] | WaitableTraitInvalidErrorType diff --git a/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-errorType.smithy b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-errorType.smithy new file mode 100644 index 00000000000..e36e8cd61c8 --- /dev/null +++ b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-errorType.smithy @@ -0,0 +1,28 @@ +namespace smithy.example + +use smithy.waiters#waitable + +@waitable( + A: { + "documentation": "A", + "acceptors": [ + { + "state": "success", + "matcher": { + "errorType": "Nope" + } + } + ] + } +) +operation A { + output: AOutput, + errors: [OhNo], +} + +structure AOutput { + foo: String, +} + +@error("client") +structure OhNo {} diff --git a/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-jmespath-syntax.errors b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-jmespath-syntax.errors new file mode 100644 index 00000000000..763428c7f91 --- /dev/null +++ b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-jmespath-syntax.errors @@ -0,0 +1,3 @@ +[ERROR] smithy.example#A: Waiter `Invalid1`, acceptor 0: Invalid JMESPath expression (||): Syntax error | WaitableTrait +[ERROR] smithy.example#A: Waiter `Invalid2`, acceptor 0: Problem found in JMESPath expression (length(`10`)): length function argument 0 error | WaitableTrait +[DANGER] smithy.example#A: Waiter `Invalid2`, acceptor 0: Waiter acceptors with a booleanEquals comparator must return a `boolean` type, but this acceptor was statically determined to return a `number` type. | WaitableTraitJmespathProblem diff --git a/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-jmespath-syntax.smithy b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-jmespath-syntax.smithy new file mode 100644 index 00000000000..2d9f6568bb5 --- /dev/null +++ b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-jmespath-syntax.smithy @@ -0,0 +1,45 @@ +namespace smithy.example + +use smithy.waiters#waitable + +@waitable( + Invalid1: { + "documentation": "A", + "acceptors": [ + { + "state": "success", + "matcher": { + "output": { + "path": "||", + "comparator": "booleanEquals", + "expected": "true" + } + } + } + ] + }, + Invalid2: { + "acceptors": [ + { + "state": "success", + "matcher": { + "output": { + // Note that this trips up the return type analysis too, + // but I want to make sure passing `10` to length is + // detected as an error. + "path": "length(`10`)", + "comparator": "booleanEquals", + "expected": "true" + } + } + } + ] + } +) +operation A { + output: AOutput +} + +structure AOutput { + foo: String, +} diff --git a/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-return-types.errors b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-return-types.errors new file mode 100644 index 00000000000..41be4b1669d --- /dev/null +++ b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-return-types.errors @@ -0,0 +1,4 @@ +[DANGER] smithy.example#A: Waiter `Invalid1`, acceptor 0: Waiter acceptors with a booleanEquals comparator must return a `boolean` type, but this acceptor was statically determined to return a `number` type. | WaitableTraitJmespathProblem +[DANGER] smithy.example#A: Waiter `Invalid1`, acceptor 1: Waiter acceptors with a stringEquals comparator must return a `string` type, but this acceptor was statically determined to return a `number` type. | WaitableTraitJmespathProblem +[DANGER] smithy.example#A: Waiter `Invalid1`, acceptor 2: Waiter acceptors with a allStringEquals comparator must return a `array` type, but this acceptor was statically determined to return a `number` type. | WaitableTraitJmespathProblem +[DANGER] smithy.example#A: Waiter `Invalid1`, acceptor 3: Waiter acceptors with a anyStringEquals comparator must return a `array` type, but this acceptor was statically determined to return a `number` type. | WaitableTraitJmespathProblem diff --git a/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-return-types.smithy b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-return-types.smithy new file mode 100644 index 00000000000..daf612f9e1e --- /dev/null +++ b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-return-types.smithy @@ -0,0 +1,58 @@ +namespace smithy.example + +use smithy.waiters#waitable + +@waitable( + Invalid1: { + "documentation": "A", + "acceptors": [ + { + "state": "success", + "matcher": { + "output": { + "path": "length(@)", + "comparator": "booleanEquals", + "expected": "true" // oops can't compare a number to a boolean + } + } + }, + { + "state": "success", + "matcher": { + "output": { + "path": "length(@)", + "comparator": "stringEquals", + "expected": "hi" // oops can't compare a number to a string + } + } + }, + { + "state": "success", + "matcher": { + "output": { + "path": "length(@)", + "comparator": "allStringEquals", + "expected": "hi" // oops can't compare a number to an array + } + } + }, + { + "state": "success", + "matcher": { + "output": { + "path": "length(@)", + "comparator": "anyStringEquals", + "expected": "hi" // oops can't compare a number to an array + } + } + } + ] + } +) +operation A { + output: AOutput +} + +structure AOutput { + foo: String, +} diff --git a/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-structure-member-access.errors b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-structure-member-access.errors new file mode 100644 index 00000000000..501a3c0f56a --- /dev/null +++ b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-structure-member-access.errors @@ -0,0 +1,2 @@ +[DANGER] smithy.example#A: Waiter `A`, acceptor 0: Problem found in JMESPath expression (missingA == 'hi'): Object field 'missingA' does not exist in object with properties [foo] (1:1) | WaitableTraitJmespathProblem +[DANGER] smithy.example#A: Waiter `B`, acceptor 0: Problem found in JMESPath expression (missingB == 'hey'): Object field 'missingB' does not exist in object with properties [baz] (1:1) | WaitableTraitJmespathProblem diff --git a/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-structure-member-access.smithy b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-structure-member-access.smithy new file mode 100644 index 00000000000..178452f8e1d --- /dev/null +++ b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-structure-member-access.smithy @@ -0,0 +1,47 @@ +namespace smithy.example + +use smithy.waiters#waitable + +@waitable( + A: { + "documentation": "A", + "acceptors": [ + { + "state": "success", + "matcher": { + "input": { + "path": "missingA == 'hi'", + "comparator": "booleanEquals", + "expected": "true" + } + } + } + ] + }, + B: { + "acceptors": [ + { + "state": "success", + "matcher": { + "output": { + "path": "missingB == 'hey'", + "comparator": "booleanEquals", + "expected": "true" + } + } + } + ] + } +) +operation A { + input: AInput, + output: AOutput, +} + +structure AInput { + foo: String, +} + +structure AOutput { + baz: String, +} diff --git a/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/minDelay-greater-than-maxDelay.errors b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/minDelay-greater-than-maxDelay.errors new file mode 100644 index 00000000000..5e83590abc7 --- /dev/null +++ b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/minDelay-greater-than-maxDelay.errors @@ -0,0 +1 @@ +[ERROR] smithy.example#A: `smithy.waiters#waitable` trait waiter named `Bad` has a `minDelay` value of 10 that is greater than its `maxDelay` value of 5 | WaitableTrait diff --git a/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/minDelay-greater-than-maxDelay.smithy b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/minDelay-greater-than-maxDelay.smithy new file mode 100644 index 00000000000..b2a0711878d --- /dev/null +++ b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/minDelay-greater-than-maxDelay.smithy @@ -0,0 +1,46 @@ +namespace smithy.example + +use smithy.waiters#waitable + +@waitable( + Bad: { + "documentation": "A", + "minDelay": 10, + "maxDelay": 5, + "acceptors": [ + { + "state": "success", + "matcher": { + "output": { + "path": "foo == 'hi'", + "comparator": "booleanEquals", + "expected": "true" + } + } + } + ] + }, + Good: { + "minDelay": 5, + "maxDelay": 10, + "acceptors": [ + { + "state": "success", + "matcher": { + "output": { + "path": "foo == 'hey'", + "comparator": "booleanEquals", + "expected": "true" + } + } + } + ] + } +) +operation A { + output: AOutput +} + +structure AOutput { + foo: String, +} diff --git a/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/not-uppercamelcase.errors b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/not-uppercamelcase.errors new file mode 100644 index 00000000000..ed4e9c2335e --- /dev/null +++ b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/not-uppercamelcase.errors @@ -0,0 +1 @@ +[ERROR] smithy.example#A: Error validating trait `smithy.waiters#waitable`.thingNotExists (map-key): String value provided for `smithy.waiters#WaiterName` must match regular expression: ^[A-Z]+[A-Za-z0-9]*$ | TraitValue diff --git a/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/not-uppercamelcase.smithy b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/not-uppercamelcase.smithy new file mode 100644 index 00000000000..bf9a018fae0 --- /dev/null +++ b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/not-uppercamelcase.smithy @@ -0,0 +1,33 @@ +namespace smithy.example + +use smithy.waiters#waitable + +@waitable( + thingNotExists: { + "documentation": "Something", + "acceptors": [ + { + "state": "success", + "matcher": { + "input": { + "path": "foo == 'hi'", + "comparator": "booleanEquals", + "expected": "true" + } + } + } + ] + } +) +operation A { + input: AInput, + output: AOutput, +} + +structure AInput { + foo: String, +} + +structure AOutput { + baz: String, +} diff --git a/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/valid-composite.errors b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/valid-composite.errors new file mode 100644 index 00000000000..e69de29bb2d diff --git a/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/valid-composite.smithy b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/valid-composite.smithy new file mode 100644 index 00000000000..121f77f7a1e --- /dev/null +++ b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/valid-composite.smithy @@ -0,0 +1,65 @@ +namespace smithy.example + +use smithy.waiters#waitable + +// These acceptors are somewhat nonsensical, but are just to assert that the +// composite loaders actually work. +@waitable( + A: { + "documentation": "A", + "acceptors": [ + { + "state": "success", + "matcher": { + "not": { + "output": { + "path" : "foo == 'hi'", + "expected": "true", + "comparator": "booleanEquals" + } + } + } + }, + { + "state": "success", + "matcher": { + "and": [ + { + "output": { + "path" : "foo == 'bye'", + "expected": "true", + "comparator": "booleanEquals" + } + }, + { + "success": true + } + ] + } + }, + { + "state": "success", + "matcher": { + // These do the same thing, but this is just to test loading works. + "or": [ + { + "not": { + "success": true + } + }, + { + "success": false + } + ] + } + }, + ] + } +) +operation A { + output: AOutput +} + +structure AOutput { + foo: String, +} diff --git a/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/valid-inputpath.errors b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/valid-inputpath.errors new file mode 100644 index 00000000000..e69de29bb2d diff --git a/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/valid-inputpath.smithy b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/valid-inputpath.smithy new file mode 100644 index 00000000000..03facfff7fa --- /dev/null +++ b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/valid-inputpath.smithy @@ -0,0 +1,48 @@ +namespace smithy.example + +use smithy.waiters#waitable + +@waitable( + A: { + "documentation": "A", + "acceptors": [ + { + "state": "success", + "matcher": { + "input": { + "path": "foo == 'hi'", + "expected": "true", + "comparator": "booleanEquals" + } + } + }, + { + "state": "success", + "matcher": { + "input": { + "path": "[foo]", + "expected": "hi", + "comparator": "allStringEquals" + } + } + }, + { + "state": "failure", + "matcher": { + "input": { + "path": "[foo]", + "expected": "hi", + "comparator": "arrayEmpty" + } + } + }, + ] + } +) +operation A { + input: AInput +} + +structure AInput { + foo: String, +} diff --git a/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/valid-waiters.errors b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/valid-waiters.errors new file mode 100644 index 00000000000..e69de29bb2d diff --git a/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/valid-waiters.smithy b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/valid-waiters.smithy new file mode 100644 index 00000000000..13703a03ad8 --- /dev/null +++ b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/valid-waiters.smithy @@ -0,0 +1,126 @@ +namespace smithy.example + +use smithy.waiters#waitable + +@waitable( + A: { + "documentation": "A", + "acceptors": [ + { + "state": "success", + "matcher": { + "output": { + "path": "foo == 'hi'", + "comparator": "booleanEquals", + "expected": "true" + } + } + } + ] + }, + B: { + "acceptors": [ + { + "state": "success", + "matcher": { + "output": { + "path": "foo == 'hey'", + "comparator": "booleanEquals", + "expected": "true" + } + } + } + ] + }, + C: { + "acceptors": [ + { + "state": "retry", + "matcher": { + "output": { + "path": "foo == 'bye'", + "comparator": "booleanEquals", + "expected": "true" + } + } + }, + { + "state": "success", + "matcher": { + "output": { + "path": "!foo", + "comparator": "booleanEquals", + "expected": "true" + } + } + }, + { + "state": "failure", + "matcher": { + "errorType": "OhNo" + } + } + ] + }, + D: { + "acceptors": [ + { + "state": "success", + "matcher": { + "errorType": OhNo + } + } + ] + }, + E: { + "acceptors": [ + { + "state": "success", + "matcher": { + "output": { + "path": "[foo]", + "expected": "hi", + "comparator": "allStringEquals" + } + } + }, + { + "state": "failure", + "matcher": { + "output": { + "path": "[foo]", + "expected": "bye", + "comparator": "anyStringEquals" + } + } + } + ] + }, + F: { + "acceptors": [ + { + "state": "success", + "matcher": { + "success": true + } + }, + { + "state": "failure", + "matcher": { + "success": false + } + } + ] + } +) +operation A { + output: AOutput, + errors: [OhNo], +} + +structure AOutput { + foo: String, +} + +@error("client") +structure OhNo {} diff --git a/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/waiter-missing-success-state.errors b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/waiter-missing-success-state.errors new file mode 100644 index 00000000000..badd2d5498a --- /dev/null +++ b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/waiter-missing-success-state.errors @@ -0,0 +1 @@ +[ERROR] smithy.example#A: No success state matcher found for `smithy.waiters#waitable` trait waiter named `MissingSuccessState` | WaitableTrait diff --git a/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/waiter-missing-success-state.smithy b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/waiter-missing-success-state.smithy new file mode 100644 index 00000000000..3efa385a537 --- /dev/null +++ b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/waiter-missing-success-state.smithy @@ -0,0 +1,23 @@ +namespace smithy.example + +use smithy.waiters#waitable + +@waitable( + MissingSuccessState: { + "documentation": "This waiter is missing a success state", + "acceptors": [ + { + "state": "failure", + "matcher": { + "success": true + } + } + ] + } +) +operation A { + input: AInputOutput, + output: AInputOutput +} + +structure AInputOutput {} diff --git a/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/model-runtime-types.smithy b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/model-runtime-types.smithy new file mode 100644 index 00000000000..c9b23767c74 --- /dev/null +++ b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/model-runtime-types.smithy @@ -0,0 +1,66 @@ +namespace smithy.example + +@length(min: 4, max: 8) +string SizedString1 + +@length(max: 1) +string SizedString2 + +@length(min: 8) +string SizedString3 + +@range(min: 100, max: 1000) +integer SizedInteger1 + +@range(max: 2) +integer SizedInteger2 + +@range(min: 2) +integer SizedInteger3 + +list StringList { + member: String, +} + +@length(min: 5, max: 1000) +list SizedStringList { + member: String, +} + +set StringSet { + member: String, +} + +@length(min: 5, max: 1000) +set SizedStringSet { + member: String, +} + +map StringListMap { + key: String, + value: StringList, +} + +@length(min: 5, max: 1000) +map SizedStringListMap { + key: String, + value: StringList, +} + +union MyUnion { + foo: String +} + +structure MyStruct { + foo: String +} + +structure RecursiveStruct { + foo: StringList, + bar: RecursiveStructList, +} + +@length(min: 1, max: 1) +list RecursiveStructList { + member: RecursiveStruct +} From 2cf182319590b1ed7a05063b170b32748df05dfc Mon Sep 17 00:00:00 2001 From: Michael Dowling Date: Thu, 5 Nov 2020 22:59:44 -0800 Subject: [PATCH 4/5] Refactor waiters This commit makes a few changes to waiters: 1. I removed and, or, and not. I couldn't think of a real use case for these as I was documenting them, so I am erring on the side of simplicity. 2. I removed the emptyArray comparator. This need more work to properly validate and specify it, and it can actually be acheived using a booleanEquals comparator by checking if a returned length of a value in JMESPath is equal to 0. No need for an extra comparator. 3. I added a new matcher named `inputOutput` that has two top level keys: input and output. This allows both input and output data to be queried *together* for successful operations, solving a longstanding use case we've had for things like making sure the number of autoscaling groups on input matches the number returned on output. 4. Given that and, or, and not was removed, there's no purpose for a standalone "input" matcher, particularly since there's now an "inputOutput" matcher. --- docs/source/1.0/spec/waiters.rst | 70 ++++------ .../amazon/smithy/waiters/Matcher.java | 123 +----------------- .../amazon/smithy/waiters/PathComparator.java | 3 - .../waiters/WaiterMatcherValidator.java | 78 +++++------ .../resources/META-INF/smithy/waiters.smithy | 33 ++--- .../input-output-on-bad-shapes.errors | 2 - ...inputOutput-operation-with-no-input.errors | 1 + ...inputOutput-operation-with-no-input.smithy | 28 ++++ ...nputOutput-operation-with-no-output.errors | 1 + ...nputOutput-operation-with-no-output.smithy | 28 ++++ .../invalid-inputoutput-path.errors | 4 + .../invalid-inputoutput-path.smithy | 48 +++++++ ...lid-output-structure-member-access.errors} | 3 +- ...lid-output-structure-member-access.smithy} | 15 --- .../errorfiles/not-uppercamelcase.smithy | 9 +- .../errorfiles/output-on-bad-shapes.errors | 1 + ...pes.smithy => output-on-bad-shapes.smithy} | 15 --- .../waiters/errorfiles/valid-composite.smithy | 65 --------- ...posite.errors => valid-inputoutput.errors} | 0 .../errorfiles/valid-inputoutput.smithy | 33 +++++ .../waiters/errorfiles/valid-inputpath.errors | 0 .../waiters/errorfiles/valid-inputpath.smithy | 48 ------- 22 files changed, 221 insertions(+), 387 deletions(-) delete mode 100644 smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/input-output-on-bad-shapes.errors create mode 100644 smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-inputOutput-operation-with-no-input.errors create mode 100644 smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-inputOutput-operation-with-no-input.smithy create mode 100644 smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-inputOutput-operation-with-no-output.errors create mode 100644 smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-inputOutput-operation-with-no-output.smithy create mode 100644 smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-inputoutput-path.errors create mode 100644 smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-inputoutput-path.smithy rename smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/{invalid-structure-member-access.errors => invalid-output-structure-member-access.errors} (50%) rename smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/{invalid-structure-member-access.smithy => invalid-output-structure-member-access.smithy} (59%) create mode 100644 smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/output-on-bad-shapes.errors rename smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/{input-output-on-bad-shapes.smithy => output-on-bad-shapes.smithy} (53%) delete mode 100644 smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/valid-composite.smithy rename smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/{valid-composite.errors => valid-inputoutput.errors} (100%) create mode 100644 smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/valid-inputoutput.smithy delete mode 100644 smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/valid-inputpath.errors delete mode 100644 smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/valid-inputpath.smithy diff --git a/docs/source/1.0/spec/waiters.rst b/docs/source/1.0/spec/waiters.rst index 9742bc54953..7aaf9aa0ef4 100644 --- a/docs/source/1.0/spec/waiters.rst +++ b/docs/source/1.0/spec/waiters.rst @@ -324,17 +324,19 @@ members MUST be set: * - Property - Type - Description - * - input - - :ref:`PathMatcher structure ` - - Matches on the input of an operation using a JMESPath_ expression. - The ``input`` matcher MUST NOT be used on operations with no input. - This matcher is checked regardless of if an operation succeeds or - fails with an error. * - output - :ref:`PathMatcher structure ` - Matches on the successful output of an operation using a - JMESPath_ expression. The ``output`` matcher MUST NOT be used on - operations with no output. This matcher is checked only if an + JMESPath_ expression. This matcher MUST NOT be used on operations + with no output. This matcher is checked only if an operation + completes successfully. + * - inputOutput + - :ref:`PathMatcher structure ` + - Matches on both the input and output of an operation using a JMESPath_ + expression. Input parameters are available through the top-level + ``input`` field, and output data is available through the top-level + ``output`` field. This matcher can only be used on operations that + define both input and output. This matcher is checked only if an operation completes successfully. * - success - ``boolean`` @@ -354,17 +356,6 @@ members MUST be set: with an operation through its ``errors`` property, though some operations might need to refer to framework errors or lower-level errors that are not defined in the model. - * - and - - ``[`` :ref:`Matcher ` ``]`` - - Matches if all matchers in the list match. The list MUST contain at - least one matcher. - * - or - - ``[`` :ref:`Matcher ` ``]`` - - Matches if any matchers in the list match. The list MUST contain at - least one matcher. - * - not - - :ref:`Matcher ` - - Matches if the given matcher is not a match. .. _waiter-PathMatcher: @@ -372,7 +363,7 @@ members MUST be set: PathMatcher structure ===================== -The ``input`` and ``output`` matchers test the result of a JMESPath_ +The ``output`` and ``inputOutput`` matchers test the result of a JMESPath_ expression against an expected value. These matchers are structures that support the following members: @@ -509,10 +500,6 @@ comparator can be set to any of the following values: - Matches if the return value of a JMESPath expression is an array and any value in the array is a string that equals an expected string. - ``array`` of ``string`` - * - arrayEmpty - - Matches if the return value of a JMESPath expression is an empty - ``array``. - - ``array`` Waiter examples @@ -577,9 +564,9 @@ triggered if the ``status`` property equals ``failed``. status: String } -The ``and`` and ``not`` matchers can be composed together. The following -example waiter transitions into a failure state if the waiter encounters -any error other than ``NotFoundError``: +Both input and output data can be queried using the ``inputOutput`` matcher. +The following example waiter completes successfully when the number of +provided groups on input matches the number of provided groups on output: .. code-block:: smithy @@ -588,34 +575,21 @@ any error other than ``NotFoundError``: use smithy.waiters#waitable @waitable( - FooExists: { + GroupExists: { acceptors: [ { - state: "failure", - matcher: { - and: [ - { - success: false - }, - { - not: { - errorType: "NotFoundError" - } - } - ] - } - }, - { - "state": "success", - matcher: { - success: true + inputOutput: { + path: "length(input.groups) == length(output.groups)", + expected: "true", + comparator: "booleanEquals" } } ] } ) - operation GetFoo { - errors: [NotFoundError] + operation ListGroups { + input: ListGroupsInput, + output: ListGroupsOutput, } diff --git a/smithy-waiters/src/main/java/software/amazon/smithy/waiters/Matcher.java b/smithy-waiters/src/main/java/software/amazon/smithy/waiters/Matcher.java index bb86ce37488..2fbdaf40c99 100644 --- a/smithy-waiters/src/main/java/software/amazon/smithy/waiters/Matcher.java +++ b/smithy-waiters/src/main/java/software/amazon/smithy/waiters/Matcher.java @@ -15,12 +15,8 @@ package software.amazon.smithy.waiters; -import java.util.ArrayList; -import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.function.Function; -import software.amazon.smithy.model.node.ArrayNode; import software.amazon.smithy.model.node.ExpectationNotMetException; import software.amazon.smithy.model.node.Node; import software.amazon.smithy.model.node.ObjectNode; @@ -43,18 +39,12 @@ private Matcher() {} public interface Visitor { T visitOutput(OutputMember outputPath); - T visitInput(InputMember inputPath); + T visitInputOutput(InputOutputMember inputOutputPath); T visitSuccess(SuccessMember success); T visitErrorType(ErrorTypeMember errorType); - T visitAnd(AndMember and); - - T visitOr(OrMember or); - - T visitNot(NotMember not); - T visitUnknown(UnknownMember unknown); } @@ -116,20 +106,14 @@ public static Matcher fromNode(Node node) { Node entryValue = entry.getValue(); switch (entryKey) { - case "input": - return new InputMember(PathMatcher.fromNode(entryValue)); case "output": return new OutputMember(PathMatcher.fromNode(entryValue)); + case "inputOutput": + return new InputOutputMember(PathMatcher.fromNode(entryValue)); case "success": return new SuccessMember(entryValue.expectBooleanNode().getValue()); case "errorType": return new ErrorTypeMember(entryValue.expectStringNode().getValue()); - case "and": - return MatcherList.fromNode(entryValue, AndMember::new); - case "or": - return MatcherList.fromNode(entryValue, OrMember::new); - case "not": - return new NotMember(fromNode(entryValue)); default: return new UnknownMember(entryKey, entryValue); } @@ -171,14 +155,14 @@ public U accept(Visitor visitor) { } } - public static final class InputMember extends PathMatcherMember { - public InputMember(PathMatcher value) { - super("input", value); + public static final class InputOutputMember extends PathMatcherMember { + public InputOutputMember(PathMatcher value) { + super("inputOutput", value); } @Override public U accept(Visitor visitor) { - return visitor.visitInput(this); + return visitor.visitInputOutput(this); } } @@ -278,97 +262,4 @@ public U accept(Visitor visitor) { return visitor.visitUnknown(this); } } - - private abstract static class MatcherList extends Matcher>> { - private final List> values; - private final String memberName; - - private MatcherList(String memberName, List> values) { - this.memberName = memberName; - this.values = values; - } - - private static T fromNode(Node node, Function>, T> constructor) { - ArrayNode values = node.expectArrayNode(); - List> result = new ArrayList<>(); - for (ObjectNode element : values.getElementsAs(ObjectNode.class)) { - result.add(Matcher.fromNode(element)); - } - return constructor.apply(result); - } - - @Override - public String getMemberName() { - return memberName; - } - - public List> getValue() { - return values; - } - - @Override - public final Node toNode() { - return Node.objectNode() - .withMember(getMemberName(), values.stream().map(Matcher::toNode).collect(ArrayNode.collect())); - } - } - - /** - * Matches if all matchers in the list are matches. - */ - public static final class AndMember extends MatcherList { - public AndMember(List> matchers) { - super("and", matchers); - } - - @Override - public U accept(Visitor visitor) { - return visitor.visitAnd(this); - } - } - - /** - * Matches if any matchers in the list are matches. - */ - public static final class OrMember extends MatcherList { - public OrMember(List> matchers) { - super("or", matchers); - } - - @Override - public U accept(Visitor visitor) { - return visitor.visitOr(this); - } - } - - /** - * Matches if the given matcher is not a match. - */ - public static final class NotMember extends Matcher> { - private final Matcher value; - - public NotMember(Matcher value) { - this.value = value; - } - - @Override - public String getMemberName() { - return "not"; - } - - @Override - public Matcher getValue() { - return value; - } - - @Override - public Node toNode() { - return Node.objectNode().withMember(getMemberName(), getValue()); - } - - @Override - public U accept(Visitor visitor) { - return visitor.visitNot(this); - } - } } diff --git a/smithy-waiters/src/main/java/software/amazon/smithy/waiters/PathComparator.java b/smithy-waiters/src/main/java/software/amazon/smithy/waiters/PathComparator.java index de431a1ba18..a65c9e5ff77 100644 --- a/smithy-waiters/src/main/java/software/amazon/smithy/waiters/PathComparator.java +++ b/smithy-waiters/src/main/java/software/amazon/smithy/waiters/PathComparator.java @@ -30,9 +30,6 @@ public enum PathComparator implements ToNode { /** Matches if any value in the list matches the expected string. */ ANY_STRING_EQUALS("anyStringEquals"), - /** Matches if the list is null or empty. */ - ARRAY_EMPTY("arrayEmpty"), - /** Matches if the return value is a string that is equal to the expected string. */ STRING_EQUALS("stringEquals"), diff --git a/smithy-waiters/src/main/java/software/amazon/smithy/waiters/WaiterMatcherValidator.java b/smithy-waiters/src/main/java/software/amazon/smithy/waiters/WaiterMatcherValidator.java index 2ed048ec502..078e4e5c753 100644 --- a/smithy-waiters/src/main/java/software/amazon/smithy/waiters/WaiterMatcherValidator.java +++ b/smithy-waiters/src/main/java/software/amazon/smithy/waiters/WaiterMatcherValidator.java @@ -16,7 +16,9 @@ package software.amazon.smithy.waiters; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Objects; import software.amazon.smithy.jmespath.ExpressionProblem; import software.amazon.smithy.jmespath.JmespathException; @@ -60,19 +62,33 @@ public List visitOutput(Matcher.OutputMember outputPath) { if (struct == null) { addEvent(Severity.ERROR, NON_SUPPRESSABLE_ERROR, "output path used on operation with no output"); } else { - validatePathMatcher(struct, outputPath.getValue()); + validatePathMatcher(createCurrentNodeFromShape(struct), outputPath.getValue()); } return events; } @Override - public List visitInput(Matcher.InputMember inputPath) { - StructureShape struct = OperationIndex.of(model).getInput(operation).orElse(null); - if (struct == null) { - addEvent(Severity.ERROR, NON_SUPPRESSABLE_ERROR, "input path used on operation with no input"); - } else { - validatePathMatcher(struct, inputPath.getValue()); + public List visitInputOutput(Matcher.InputOutputMember inputOutputMember) { + OperationIndex index = OperationIndex.of(model); + + StructureShape input = index.getInput(operation).orElse(null); + if (input == null) { + addEvent(Severity.ERROR, NON_SUPPRESSABLE_ERROR, "inputOutput path used on operation with no input"); + } + + StructureShape output = index.getOutput(operation).orElse(null); + if (output == null) { + addEvent(Severity.ERROR, NON_SUPPRESSABLE_ERROR, "inputOutput path used on operation with no output"); } + + if (input != null && output != null) { + Map composedMap = new LinkedHashMap<>(); + composedMap.put("input", createCurrentNodeFromShape(input).expectObjectValue()); + composedMap.put("output", createCurrentNodeFromShape(output).expectObjectValue()); + LiteralExpression composedData = new LiteralExpression(composedMap); + validatePathMatcher(composedData, inputOutputMember.getValue()); + } + return events; } @@ -101,36 +117,14 @@ public List visitErrorType(Matcher.ErrorTypeMember errorType) { return events; } - @Override - public List visitAnd(Matcher.AndMember and) { - for (Matcher matcher : and.getValue()) { - matcher.accept(this); - } - return events; - } - - @Override - public List visitOr(Matcher.OrMember or) { - for (Matcher matcher : or.getValue()) { - matcher.accept(this); - } - return events; - } - - @Override - public List visitNot(Matcher.NotMember not) { - not.getValue().accept(this); - return events; - } - @Override public List visitUnknown(Matcher.UnknownMember unknown) { // This is validated by model validation. No need to do more here. return events; } - private void validatePathMatcher(StructureShape struct, PathMatcher pathMatcher) { - RuntimeType returnType = validatePath(struct, pathMatcher.getPath()); + private void validatePathMatcher(LiteralExpression input, PathMatcher pathMatcher) { + RuntimeType returnType = validatePath(input, pathMatcher.getPath()); switch (pathMatcher.getComparator()) { case BOOLEAN_EQUALS: @@ -151,19 +145,10 @@ private void validatePathMatcher(StructureShape struct, PathMatcher pathMatcher) } } - private void validateReturnType(PathComparator comparator, RuntimeType expected, RuntimeType actual) { - if (actual != RuntimeType.ANY && actual != expected) { - addEvent(Severity.DANGER, JMESPATH_PROBLEM, String.format( - "Waiter acceptors with a %s comparator must return a `%s` type, but this acceptor was " - + "statically determined to return a `%s` type.", - comparator, expected, actual)); - } - } - - private RuntimeType validatePath(StructureShape struct, String path) { + private RuntimeType validatePath(LiteralExpression input, String path) { try { JmespathExpression expression = JmespathExpression.parse(path); - LinterResult result = expression.lint(createCurrentNodeFromShape(struct)); + LinterResult result = expression.lint(input); for (ExpressionProblem problem : result.getProblems()) { addJmespathEvent(path, problem); } @@ -175,6 +160,15 @@ private RuntimeType validatePath(StructureShape struct, String path) { } } + private void validateReturnType(PathComparator comparator, RuntimeType expected, RuntimeType actual) { + if (actual != RuntimeType.ANY && actual != expected) { + addEvent(Severity.DANGER, JMESPATH_PROBLEM, String.format( + "Waiter acceptors with a %s comparator must return a `%s` type, but this acceptor was " + + "statically determined to return a `%s` type.", + comparator, expected, actual)); + } + } + // Lint using an ANY type or using the modeled shape as the starting data. private LiteralExpression createCurrentNodeFromShape(Shape shape) { return shape == null diff --git a/smithy-waiters/src/main/resources/META-INF/smithy/waiters.smithy b/smithy-waiters/src/main/resources/META-INF/smithy/waiters.smithy index 5afd2473062..4bddfa6c20d 100644 --- a/smithy-waiters/src/main/resources/META-INF/smithy/waiters.smithy +++ b/smithy-waiters/src/main/resources/META-INF/smithy/waiters.smithy @@ -86,13 +86,18 @@ string AcceptorState @private union Matcher { - /// Matches on the input of an operation using a JMESPath expression. - input: PathMatcher, - /// Matches on the successful output of an operation using a /// JMESPath expression. output: PathMatcher, + /// Matches on both the input and output of an operation using a JMESPath + /// expression. Input parameters are available through the top-level + /// `input` field, and output data is available through the top-level + /// `output` field. This matcher can only be used on operations that + /// define both input and output. This matcher is checked only if an + /// operation completes successfully. + inputOutput: PathMatcher, + /// Matches if an operation returns an error and the error matches /// the expected error type. If an absolute shape ID is provided, the /// error is matched exactly on the shape ID. A shape name can be @@ -103,15 +108,6 @@ union Matcher { /// response. When set to `false`, matches when an operation fails with /// any error. success: Boolean, - - /// Matches if all matchers in the list match. - and: MatcherList, - - /// Matches if any matchers in the list match. - or: MatcherList, - - /// Matches if the given matcher is not a match. - not: Matcher, } @private @@ -151,18 +147,7 @@ structure PathMatcher { "name": "ANY_STRING_EQUALS", "value": "anyStringEquals", "documentation": "Matches if any value in the list matches the expected string." - }, - { - "name": "ARRAY_EMPTY", - "value": "arrayEmpty", - "documentation": "Matches if the return value is an array that is null or empty." - }, + } ]) @private string PathComparator - -@private -@length(min: 1) -list MatcherList { - member: Matcher, -} diff --git a/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/input-output-on-bad-shapes.errors b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/input-output-on-bad-shapes.errors deleted file mode 100644 index fd96f55eab8..00000000000 --- a/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/input-output-on-bad-shapes.errors +++ /dev/null @@ -1,2 +0,0 @@ -[ERROR] smithy.example#A: Waiter `A`, acceptor 0: input path used on operation with no input | WaitableTrait -[ERROR] smithy.example#A: Waiter `B`, acceptor 0: output path used on operation with no output | WaitableTrait diff --git a/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-inputOutput-operation-with-no-input.errors b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-inputOutput-operation-with-no-input.errors new file mode 100644 index 00000000000..a9db23bb393 --- /dev/null +++ b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-inputOutput-operation-with-no-input.errors @@ -0,0 +1 @@ +[ERROR] smithy.example#A: Waiter `A`, acceptor 0: inputOutput path used on operation with no input | WaitableTrait diff --git a/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-inputOutput-operation-with-no-input.smithy b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-inputOutput-operation-with-no-input.smithy new file mode 100644 index 00000000000..f42d926e4d9 --- /dev/null +++ b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-inputOutput-operation-with-no-input.smithy @@ -0,0 +1,28 @@ +namespace smithy.example + +use smithy.waiters#waitable + +@waitable( + A: { + "documentation": "A", + "acceptors": [ + { + "state": "success", + "matcher": { + "inputOutput": { + "path": "output.foo == 'hi'", + "expected": "true", + "comparator": "booleanEquals" + } + } + } + ] + } +) +operation A { + output: AOutput +} + +structure AOutput { + foo: String, +} diff --git a/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-inputOutput-operation-with-no-output.errors b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-inputOutput-operation-with-no-output.errors new file mode 100644 index 00000000000..70a55e529eb --- /dev/null +++ b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-inputOutput-operation-with-no-output.errors @@ -0,0 +1 @@ +[ERROR] smithy.example#A: Waiter `A`, acceptor 0: inputOutput path used on operation with no output | WaitableTrait diff --git a/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-inputOutput-operation-with-no-output.smithy b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-inputOutput-operation-with-no-output.smithy new file mode 100644 index 00000000000..b59e9a1f327 --- /dev/null +++ b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-inputOutput-operation-with-no-output.smithy @@ -0,0 +1,28 @@ +namespace smithy.example + +use smithy.waiters#waitable + +@waitable( + A: { + "documentation": "A", + "acceptors": [ + { + "state": "success", + "matcher": { + "inputOutput": { + "path": "output.foo == 'hi'", + "expected": "true", + "comparator": "booleanEquals" + } + } + } + ] + } +) +operation A { + input: AInput +} + +structure AInput { + foo: String, +} diff --git a/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-inputoutput-path.errors b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-inputoutput-path.errors new file mode 100644 index 00000000000..b2cb32a7ff8 --- /dev/null +++ b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-inputoutput-path.errors @@ -0,0 +1,4 @@ +[DANGER] smithy.example#A: Waiter `A`, acceptor 0: Problem found in JMESPath expression (input.foop == output.bazz): Object field 'bazz' does not exist in object with properties [baz] (1:22) | WaitableTraitJmespathProblem +[DANGER] smithy.example#A: Waiter `A`, acceptor 0: Problem found in JMESPath expression (input.foop == output.bazz): Object field 'foop' does not exist in object with properties [foo] (1:7) | WaitableTraitJmespathProblem +[DANGER] smithy.example#A: Waiter `B`, acceptor 0: Problem found in JMESPath expression (foo == baz): Object field 'baz' does not exist in object with properties [input, output] (1:8) | WaitableTraitJmespathProblem +[DANGER] smithy.example#A: Waiter `B`, acceptor 0: Problem found in JMESPath expression (foo == baz): Object field 'foo' does not exist in object with properties [input, output] (1:1) | WaitableTraitJmespathProblem diff --git a/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-inputoutput-path.smithy b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-inputoutput-path.smithy new file mode 100644 index 00000000000..34a2b1b4b4f --- /dev/null +++ b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-inputoutput-path.smithy @@ -0,0 +1,48 @@ +namespace smithy.example + +use smithy.waiters#waitable + +@waitable( + A: { + "documentation": "A", + "acceptors": [ + { + "state": "success", + "matcher": { + "inputOutput": { + "path": "input.foop == output.bazz", + "expected": "true", + "comparator": "booleanEquals" + } + } + } + ] + }, + B: { + "documentation": "A", + "acceptors": [ + { + "state": "success", + "matcher": { + "inputOutput": { + "path": "foo == baz", // needs top-level input or output + "expected": "true", + "comparator": "booleanEquals" + } + } + } + ] + } +) +operation A { + input: AInput, + output: AOutput, +} + +structure AInput { + foo: String, +} + +structure AOutput { + baz: String, +} diff --git a/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-structure-member-access.errors b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-output-structure-member-access.errors similarity index 50% rename from smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-structure-member-access.errors rename to smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-output-structure-member-access.errors index 501a3c0f56a..11d3ad6e1dd 100644 --- a/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-structure-member-access.errors +++ b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-output-structure-member-access.errors @@ -1,2 +1 @@ -[DANGER] smithy.example#A: Waiter `A`, acceptor 0: Problem found in JMESPath expression (missingA == 'hi'): Object field 'missingA' does not exist in object with properties [foo] (1:1) | WaitableTraitJmespathProblem -[DANGER] smithy.example#A: Waiter `B`, acceptor 0: Problem found in JMESPath expression (missingB == 'hey'): Object field 'missingB' does not exist in object with properties [baz] (1:1) | WaitableTraitJmespathProblem +[DANGER] smithy.example#A: Waiter `A`, acceptor 0: Problem found in JMESPath expression (missingB == 'hey'): Object field 'missingB' does not exist in object with properties [baz] (1:1) | WaitableTraitJmespathProblem diff --git a/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-structure-member-access.smithy b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-output-structure-member-access.smithy similarity index 59% rename from smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-structure-member-access.smithy rename to smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-output-structure-member-access.smithy index 178452f8e1d..aacec15a31b 100644 --- a/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-structure-member-access.smithy +++ b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-output-structure-member-access.smithy @@ -4,21 +4,6 @@ use smithy.waiters#waitable @waitable( A: { - "documentation": "A", - "acceptors": [ - { - "state": "success", - "matcher": { - "input": { - "path": "missingA == 'hi'", - "comparator": "booleanEquals", - "expected": "true" - } - } - } - ] - }, - B: { "acceptors": [ { "state": "success", diff --git a/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/not-uppercamelcase.smithy b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/not-uppercamelcase.smithy index bf9a018fae0..5fa17e81123 100644 --- a/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/not-uppercamelcase.smithy +++ b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/not-uppercamelcase.smithy @@ -9,8 +9,8 @@ use smithy.waiters#waitable { "state": "success", "matcher": { - "input": { - "path": "foo == 'hi'", + "output": { + "path": "baz == 'hi'", "comparator": "booleanEquals", "expected": "true" } @@ -20,14 +20,9 @@ use smithy.waiters#waitable } ) operation A { - input: AInput, output: AOutput, } -structure AInput { - foo: String, -} - structure AOutput { baz: String, } diff --git a/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/output-on-bad-shapes.errors b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/output-on-bad-shapes.errors new file mode 100644 index 00000000000..86bcb9ada8f --- /dev/null +++ b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/output-on-bad-shapes.errors @@ -0,0 +1 @@ +[ERROR] smithy.example#A: Waiter `A`, acceptor 0: output path used on operation with no output | WaitableTrait diff --git a/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/input-output-on-bad-shapes.smithy b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/output-on-bad-shapes.smithy similarity index 53% rename from smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/input-output-on-bad-shapes.smithy rename to smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/output-on-bad-shapes.smithy index cdb8edd7b01..1b0e401aa7a 100644 --- a/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/input-output-on-bad-shapes.smithy +++ b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/output-on-bad-shapes.smithy @@ -4,21 +4,6 @@ use smithy.waiters#waitable @waitable( A: { - "documentation": "A", - "acceptors": [ - { - "state": "success", - "matcher": { - "input": { - "path": "foo == 'hi'", - "comparator": "booleanEquals", - "expected": "true" - } - } - } - ] - }, - B: { "acceptors": [ { "state": "success", diff --git a/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/valid-composite.smithy b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/valid-composite.smithy deleted file mode 100644 index 121f77f7a1e..00000000000 --- a/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/valid-composite.smithy +++ /dev/null @@ -1,65 +0,0 @@ -namespace smithy.example - -use smithy.waiters#waitable - -// These acceptors are somewhat nonsensical, but are just to assert that the -// composite loaders actually work. -@waitable( - A: { - "documentation": "A", - "acceptors": [ - { - "state": "success", - "matcher": { - "not": { - "output": { - "path" : "foo == 'hi'", - "expected": "true", - "comparator": "booleanEquals" - } - } - } - }, - { - "state": "success", - "matcher": { - "and": [ - { - "output": { - "path" : "foo == 'bye'", - "expected": "true", - "comparator": "booleanEquals" - } - }, - { - "success": true - } - ] - } - }, - { - "state": "success", - "matcher": { - // These do the same thing, but this is just to test loading works. - "or": [ - { - "not": { - "success": true - } - }, - { - "success": false - } - ] - } - }, - ] - } -) -operation A { - output: AOutput -} - -structure AOutput { - foo: String, -} diff --git a/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/valid-composite.errors b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/valid-inputoutput.errors similarity index 100% rename from smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/valid-composite.errors rename to smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/valid-inputoutput.errors diff --git a/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/valid-inputoutput.smithy b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/valid-inputoutput.smithy new file mode 100644 index 00000000000..1097e89d925 --- /dev/null +++ b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/valid-inputoutput.smithy @@ -0,0 +1,33 @@ +namespace smithy.example + +use smithy.waiters#waitable + +@waitable( + A: { + "documentation": "A", + "acceptors": [ + { + "state": "success", + "matcher": { + "inputOutput": { + "path": "input.foo == output.baz", + "expected": "true", + "comparator": "booleanEquals" + } + } + } + ] + } +) +operation A { + input: AInput, + output: AOutput +} + +structure AInput { + foo: String, +} + +structure AOutput { + baz: String, +} diff --git a/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/valid-inputpath.errors b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/valid-inputpath.errors deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/valid-inputpath.smithy b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/valid-inputpath.smithy deleted file mode 100644 index 03facfff7fa..00000000000 --- a/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/valid-inputpath.smithy +++ /dev/null @@ -1,48 +0,0 @@ -namespace smithy.example - -use smithy.waiters#waitable - -@waitable( - A: { - "documentation": "A", - "acceptors": [ - { - "state": "success", - "matcher": { - "input": { - "path": "foo == 'hi'", - "expected": "true", - "comparator": "booleanEquals" - } - } - }, - { - "state": "success", - "matcher": { - "input": { - "path": "[foo]", - "expected": "hi", - "comparator": "allStringEquals" - } - } - }, - { - "state": "failure", - "matcher": { - "input": { - "path": "[foo]", - "expected": "hi", - "comparator": "arrayEmpty" - } - } - }, - ] - } -) -operation A { - input: AInput -} - -structure AInput { - foo: String, -} From 5b72625af7dfc4a1abba251dffcd13ffd40ac374 Mon Sep 17 00:00:00 2001 From: Michael Dowling Date: Fri, 6 Nov 2020 13:55:07 -0800 Subject: [PATCH 5/5] Incorporate waiters PR feedback --- docs/source/1.0/spec/waiters.rst | 30 +++++++++---------- docs/source/conf.py | 1 - .../amazon/smithy/waiters/AcceptorState.java | 2 +- .../waiters/ModelRuntimeTypeGenerator.java | 11 ++++--- .../amazon/smithy/waiters/PathComparator.java | 8 ++--- .../amazon/smithy/waiters/PathMatcher.java | 4 +-- .../resources/META-INF/smithy/waiters.smithy | 7 ++--- 7 files changed, 30 insertions(+), 33 deletions(-) diff --git a/docs/source/1.0/spec/waiters.rst b/docs/source/1.0/spec/waiters.rst index 7aaf9aa0ef4..b69fd8031ce 100644 --- a/docs/source/1.0/spec/waiters.rst +++ b/docs/source/1.0/spec/waiters.rst @@ -31,17 +31,14 @@ following client pseudocode: ``smithy.waiters#waitable`` trait ================================= -Waiters are defined on :ref:`operations ` using the -``smithy.waiters#waitable`` trait. - -Trait summary +Summary Indicates that an operation has various named "waiters" that can be used to poll a resource until it enters a desired state. Trait selector ``operation :not(-[input, output]-> structure > member > union[trait|streaming])`` (Operations that do not use :ref:`event streams ` in their input or output) -Trait value +Value type A ``map`` of :ref:`waiter names ` to :ref:`Waiter structures `. @@ -53,6 +50,8 @@ exists: namespace com.amazonaws.s3 + use smithy.waiters#waitable + @waitable( BucketExists: { documentation: "Wait until a bucket exists", @@ -139,7 +138,7 @@ waiter implementations perform the following steps: 3. Stop waiting if the acceptor transitions the waiter to the ``success`` or ``failure`` state. -4. If none of the acceptors are matched and an error was encountered while +4. If none of the acceptors are matched *and* an error was encountered while calling the operation, then transition to the ``failure`` state and stop waiting. 5. Transition the waiter to the ``retry`` state, follow the process @@ -155,7 +154,7 @@ Waiter implementations MUST delay for a period of time before attempting a retry. The amount of time a waiter delays between retries is computed using `exponential backoff`_ through the following algorithm: -* Let ``attempt`` be the number retry attempts. +* Let ``attempt`` be the number of retry attempts. * Let ``minDelay`` be the minimum amount of time to delay between retries in seconds, specified by the ``minDelay`` property of a :ref:`waiter ` with a default of 2. @@ -182,8 +181,8 @@ or equal to ``minDelay``, then set ``delay`` to ``remainingTime`` minus needlessly only to exceed ``maxWaitTime`` before issuing a final request. Using the default ``minDelay`` of 2, ``maxDelay`` of 120, a ``maxWaitTime`` -of 300 (or 5 minutes), and assuming that requests complete in 0 seconds -(for example purposes only), delays are computed as followed: +of 300 (5 minutes), and assuming that requests complete in 0 seconds +(for example purposes only), delays are computed as follows: .. list-table:: :header-rows: 1 @@ -335,8 +334,8 @@ members MUST be set: - Matches on both the input and output of an operation using a JMESPath_ expression. Input parameters are available through the top-level ``input`` field, and output data is available through the top-level - ``output`` field. This matcher can only be used on operations that - define both input and output. This matcher is checked only if an + ``output`` field. This matcher MUST NOT be used on operations that + do not define input or output. This matcher is checked only if an operation completes successfully. * - success - ``boolean`` @@ -488,9 +487,10 @@ comparator can be set to any of the following values: that is equal to an expected string. - ``string`` * - booleanEquals - - Matches if the return value of a JMESPath expression is a boolean. - The ``expected`` value of a ``PathMatcher`` MUST be set to "true" - or "false" to match the corresponding boolean value. + - Matches if the return value of a JMESPath expression is a boolean + that is equal to an expected boolean. The ``expected`` value of a + ``PathMatcher`` MUST be set to "true" or "false" to match the + corresponding boolean value. - ``boolean`` * - allStringEquals - Matches if the return value of a JMESPath expression is an array and @@ -650,7 +650,7 @@ Implicit acceptors are unnecessary and can quickly become incomplete as new resource states and errors are added. Waiters have 2 implicit :ref:`acceptors `: -* (Step 4) - If none of the acceptors are matched and an error was +* (Step 4) - If none of the acceptors are matched *and* an error was encountered while calling the operation, then transition to the ``failure`` state and stop waiting. * (Step 5) - Transition the waiter to the ``retry`` state, follow the diff --git a/docs/source/conf.py b/docs/source/conf.py index a63e2743461..dc52b2bcaae 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -28,7 +28,6 @@ extensions = ['sphinx_tabs.tabs', # We use redirects to be able to change page names. 'sphinxcontrib.redirects', - 'sphinx.ext.imgmath', 'smithy'] # Add any paths that contain templates here, relative to this directory. diff --git a/smithy-waiters/src/main/java/software/amazon/smithy/waiters/AcceptorState.java b/smithy-waiters/src/main/java/software/amazon/smithy/waiters/AcceptorState.java index 9ad29d40186..11f6c460a3e 100644 --- a/smithy-waiters/src/main/java/software/amazon/smithy/waiters/AcceptorState.java +++ b/smithy-waiters/src/main/java/software/amazon/smithy/waiters/AcceptorState.java @@ -32,7 +32,7 @@ public enum AcceptorState implements ToNode { /** Transition to a final failure state. */ FAILURE, - /** Transition to a final retry state. */ + /** Transition to an intermediate retry state. */ RETRY; @Override diff --git a/smithy-waiters/src/main/java/software/amazon/smithy/waiters/ModelRuntimeTypeGenerator.java b/smithy-waiters/src/main/java/software/amazon/smithy/waiters/ModelRuntimeTypeGenerator.java index f0c6441ad8f..e1c6e9075af 100644 --- a/smithy-waiters/src/main/java/software/amazon/smithy/waiters/ModelRuntimeTypeGenerator.java +++ b/smithy-waiters/src/main/java/software/amazon/smithy/waiters/ModelRuntimeTypeGenerator.java @@ -152,11 +152,10 @@ private Object createListOrSet(Shape shape, MemberShape member) { }); } - // Visits members and mutates a copy of the current set of - // visited shapes rather than a shared set. This a shape to - // be used multiple times in the closure of a single shape - // without causing the reuse of the shape to always be - // assume to be a recursive type. + // Visits members and mutates a copy of the current set of visited + // shapes rather than a shared set. This allows a shape to be used + // multiple times in the closure of a single shape without causing the + // reuse of the shape to always be assumed to be a recursive type. private Object withCopiedVisitors(Supplier supplier) { // Account for recursive shapes at the current Set visitedCopy = new HashSet<>(visited); @@ -253,7 +252,7 @@ private int computeLength(Shape shape) { } private double computeRange(Shape shape) { - // Create a random string that does not exceed or go under the length trait. + // Create a random string that does not exceed or go under the range trait. double i = 8; if (shape.hasTrait(RangeTrait.class)) { diff --git a/smithy-waiters/src/main/java/software/amazon/smithy/waiters/PathComparator.java b/smithy-waiters/src/main/java/software/amazon/smithy/waiters/PathComparator.java index a65c9e5ff77..9b6dcd4a633 100644 --- a/smithy-waiters/src/main/java/software/amazon/smithy/waiters/PathComparator.java +++ b/smithy-waiters/src/main/java/software/amazon/smithy/waiters/PathComparator.java @@ -20,7 +20,7 @@ import software.amazon.smithy.model.node.ToNode; /** - * Defines a comparison to perform in a ListPathMatcher. + * Defines a comparison to perform in a PathMatcher. */ public enum PathComparator implements ToNode { @@ -43,9 +43,9 @@ public enum PathComparator implements ToNode { } /** - * Creates a {@code ListPathComparator} from a {@link Node}. - * @param node Node to create the {@code ListPathComparator} from. - * @return Returns the created {@code ListPathComparator}. + * Creates a {@code PathComparator} from a {@link Node}. + * @param node Node to create the {@code PathComparator} from. + * @return Returns the created {@code PathComparator}. * @throws ExpectationNotMetException if the given {@code node} is invalid. */ public static PathComparator fromNode(Node node) { diff --git a/smithy-waiters/src/main/java/software/amazon/smithy/waiters/PathMatcher.java b/smithy-waiters/src/main/java/software/amazon/smithy/waiters/PathMatcher.java index 6c766169a5e..e3012d8e6ec 100644 --- a/smithy-waiters/src/main/java/software/amazon/smithy/waiters/PathMatcher.java +++ b/smithy-waiters/src/main/java/software/amazon/smithy/waiters/PathMatcher.java @@ -80,8 +80,8 @@ public PathComparator getComparator() { /** * Creates a new instance from a {@link Node}. * - * @param node Node tom create the ListPathMatcher from. - * @return Returns the created ListPathMatcher. + * @param node Node tom create the PathMatcher from. + * @return Returns the created PathMatcher. * @throws ExpectationNotMetException if the given Node is invalid. */ public static PathMatcher fromNode(Node node) { diff --git a/smithy-waiters/src/main/resources/META-INF/smithy/waiters.smithy b/smithy-waiters/src/main/resources/META-INF/smithy/waiters.smithy index 4bddfa6c20d..3546218374b 100644 --- a/smithy-waiters/src/main/resources/META-INF/smithy/waiters.smithy +++ b/smithy-waiters/src/main/resources/META-INF/smithy/waiters.smithy @@ -29,9 +29,8 @@ structure Waiter { minDelay: WaiterDelay, /// The maximum amount of time in seconds to delay between each retry. - /// This value defaults to 256 if not specified (or, 4 minutes and 16 - /// seconds). If specified, this value MUST be greater than or equal - /// to 1. + /// This value defaults to 120 if not specified (or, 2 minutes). If + /// specified, this value MUST be greater than or equal to 1. maxDelay: WaiterDelay, } @@ -126,7 +125,7 @@ structure PathMatcher { comparator: PathComparator, } -/// Defines a comparison to perform in a ListPathMatcher. +/// Defines a comparison to perform in a PathMatcher. @enum([ { "name": "STRING_EQUALS",