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..b69fd8031ce --- /dev/null +++ b/docs/source/1.0/spec/waiters.rst @@ -0,0 +1,783 @@ +.. _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 +================================= + +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) +Value type + 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 + + use smithy.waiters#waitable + + @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 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. +* 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 (5 minutes), and assuming that requests complete in 0 seconds +(for example purposes only), delays are computed as follows: + +.. 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 + * - output + - :ref:`PathMatcher structure ` + - Matches on the successful output of an operation using a + 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 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`` + - 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. + + +.. _waiter-PathMatcher: + +PathMatcher structure +===================== + +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: + +.. 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 + 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 + 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`` + + +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 + } + +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 + + namespace smithy.example + + use smithy.waiters#waitable + + @waitable( + GroupExists: { + acceptors: [ + { + inputOutput: { + path: "length(input.groups) == length(output.groups)", + expected: "true", + comparator: "booleanEquals" + } + } + ] + } + ) + operation ListGroups { + input: ListGroupsInput, + output: ListGroupsOutput, + } + + +.. _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/settings.gradle b/settings.gradle index aa50eee3e48..3b36176d0b5 100644 --- a/settings.gradle +++ b/settings.gradle @@ -22,3 +22,5 @@ include ":smithy-jsonschema" include ":smithy-openapi" include ":smithy-utils" include ":smithy-protocol-test-traits" +include ':smithy-jmespath' +include ":smithy-waiters" diff --git a/smithy-jmespath/README.md b/smithy-jmespath/README.md new file mode 100644 index 00000000000..0cfccbfb567 --- /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 parse +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..73ce90cef9e --- /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.ComparatorExpression; +import software.amazon.smithy.jmespath.ast.CurrentExpression; +import software.amazon.smithy.jmespath.ast.ExpressionTypeExpression; +import software.amazon.smithy.jmespath.ast.FieldExpression; +import software.amazon.smithy.jmespath.ast.FilterProjectionExpression; +import software.amazon.smithy.jmespath.ast.FlattenExpression; +import software.amazon.smithy.jmespath.ast.FunctionExpression; +import software.amazon.smithy.jmespath.ast.IndexExpression; +import software.amazon.smithy.jmespath.ast.LiteralExpression; +import software.amazon.smithy.jmespath.ast.MultiSelectHashExpression; +import software.amazon.smithy.jmespath.ast.MultiSelectListExpression; +import software.amazon.smithy.jmespath.ast.NotExpression; +import software.amazon.smithy.jmespath.ast.ObjectProjectionExpression; +import software.amazon.smithy.jmespath.ast.OrExpression; +import software.amazon.smithy.jmespath.ast.ProjectionExpression; +import software.amazon.smithy.jmespath.ast.SliceExpression; +import software.amazon.smithy.jmespath.ast.Subexpression; + +/** + * Visits each type of AST node. + * + * @param Value returned from the visitor. + */ +public interface ExpressionVisitor { + + T visitComparator(ComparatorExpression expression); + + T visitCurrentNode(CurrentExpression expression); + + T visitExpressionType(ExpressionTypeExpression 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..4416af822dd --- /dev/null +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/FunctionDefinition.java @@ -0,0 +1,93 @@ +/* + * 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; + +/** + * Defines the positional arguments, variadic arguments, and return value + * of JMESPath functions. + */ +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.expectArrayValue(); + 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..712b774ed3e --- /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.expectStringValue(); + case '{': + return parseJsonObject(); + case '[': + return parseJsonArray(); + default: // - | 0-9 + // Backtrack. + position--; + column--; + return parseNumber().value.expectNumberValue(); + } + } + + 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.expectStringValue(); + 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..a068e151bbc --- /dev/null +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/LinterResult.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; + +import java.util.Objects; +import java.util.Set; + +/** + * Contains the result of {@link JmespathExpression#lint}. + */ +public final class LinterResult { + + 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; + } + + @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..f46809bb5ff --- /dev/null +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/Parser.java @@ -0,0 +1,405 @@ +/* + * 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.ComparatorExpression; +import software.amazon.smithy.jmespath.ast.ComparatorType; +import software.amazon.smithy.jmespath.ast.CurrentExpression; +import software.amazon.smithy.jmespath.ast.ExpressionTypeExpression; +import software.amazon.smithy.jmespath.ast.FieldExpression; +import software.amazon.smithy.jmespath.ast.FilterProjectionExpression; +import software.amazon.smithy.jmespath.ast.FlattenExpression; +import software.amazon.smithy.jmespath.ast.FunctionExpression; +import software.amazon.smithy.jmespath.ast.IndexExpression; +import software.amazon.smithy.jmespath.ast.LiteralExpression; +import software.amazon.smithy.jmespath.ast.MultiSelectHashExpression; +import software.amazon.smithy.jmespath.ast.MultiSelectListExpression; +import software.amazon.smithy.jmespath.ast.NotExpression; +import software.amazon.smithy.jmespath.ast.ObjectProjectionExpression; +import software.amazon.smithy.jmespath.ast.OrExpression; +import software.amazon.smithy.jmespath.ast.ProjectionExpression; +import software.amazon.smithy.jmespath.ast.SliceExpression; +import software.amazon.smithy.jmespath.ast.Subexpression; + +/** + * 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.FLATTEN, + TokenType.FILTER, + TokenType.EQUAL, + TokenType.NOT_EQUAL, + TokenType.GREATER_THAN, + TokenType.GREATER_THAN_EQUAL, + TokenType.LESS_THAN, + 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; + 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.expectStringValue(), arguments, token.line, token.column); + } else { + return new FieldExpression(token.value.expectStringValue(), 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 ExpressionTypeExpression(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.expectNumberValue().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.expectStringValue(), 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 ComparatorExpression(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..ec42e58f2fe --- /dev/null +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/RuntimeType.java @@ -0,0 +1,163 @@ +/* + * 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; +import software.amazon.smithy.jmespath.ast.ComparatorType; +import software.amazon.smithy.jmespath.ast.LiteralExpression; + +public enum RuntimeType { + + 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/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..1d165cbacca --- /dev/null +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/TypeChecker.java @@ -0,0 +1,406 @@ +/* + * 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.ComparatorExpression; +import software.amazon.smithy.jmespath.ast.ComparatorType; +import software.amazon.smithy.jmespath.ast.CurrentExpression; +import software.amazon.smithy.jmespath.ast.ExpressionTypeExpression; +import software.amazon.smithy.jmespath.ast.FieldExpression; +import software.amazon.smithy.jmespath.ast.FilterProjectionExpression; +import software.amazon.smithy.jmespath.ast.FlattenExpression; +import software.amazon.smithy.jmespath.ast.FunctionExpression; +import software.amazon.smithy.jmespath.ast.IndexExpression; +import software.amazon.smithy.jmespath.ast.LiteralExpression; +import software.amazon.smithy.jmespath.ast.MultiSelectHashExpression; +import software.amazon.smithy.jmespath.ast.MultiSelectListExpression; +import software.amazon.smithy.jmespath.ast.NotExpression; +import software.amazon.smithy.jmespath.ast.ObjectProjectionExpression; +import software.amazon.smithy.jmespath.ast.OrExpression; +import software.amazon.smithy.jmespath.ast.ProjectionExpression; +import software.amazon.smithy.jmespath.ast.SliceExpression; +import software.amazon.smithy.jmespath.ast.Subexpression; + +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), isArray)); + // TODO: support array + FUNCTIONS.put("max", new FunctionDefinition(NUMBER, isArray)); + 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))); + 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))); + 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 visitComparator(ComparatorExpression expression) { + LiteralExpression left = expression.getLeft().accept(this); + LiteralExpression right = expression.getRight().accept(this); + LiteralExpression result = left.getType().compare(left, right, expression.getComparator()); + + if (result.getType() == RuntimeType.NULL) { + badComparator(expression, left.getType(), expression.getComparator()); + } + + return result; + } + + @Override + public LiteralExpression visitCurrentNode(CurrentExpression expression) { + return current; + } + + @Override + 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)); + 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.expectArrayValue()) { + LiteralExpression element = LiteralExpression.from(value); + if (element.isArrayValue()) { + flattened.addAll(element.expectArrayValue()); + } 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.expectObjectValue().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.expectArrayValue().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.expectArrayValue()) { + 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.expectObjectValue().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.expectArrayValue().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.expectArrayValue()) { + 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..3cbab9e9f38 --- /dev/null +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/AndExpression.java @@ -0,0 +1,41 @@ +/* + * 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. + * + * @see And Expressions + */ +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/ComparatorExpression.java b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/ComparatorExpression.java new file mode 100644 index 00000000000..29896a2de27 --- /dev/null +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/ComparatorExpression.java @@ -0,0 +1,85 @@ +/* + * 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. + * + * @see Comparator expression as defined in Filter Expressions + */ +public final class ComparatorExpression extends BinaryExpression { + + private final ComparatorType comparator; + + public ComparatorExpression(ComparatorType comparator, JmespathExpression left, JmespathExpression right) { + this(comparator, left, right, 1, 1); + } + + public ComparatorExpression( + 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.visitComparator(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 ComparatorExpression)) { + return false; + } + ComparatorExpression that = (ComparatorExpression) 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/ComparatorType.java b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/ComparatorType.java new file mode 100644 index 00000000000..9ce4d4b6fde --- /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/CurrentExpression.java b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/CurrentExpression.java new file mode 100644 index 00000000000..f2a2dd4c6a9 --- /dev/null +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/CurrentExpression.java @@ -0,0 +1,55 @@ +/* + * 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. + * + * 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/ExpressionTypeExpression.java b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/ExpressionTypeExpression.java new file mode 100644 index 00000000000..d8225390367 --- /dev/null +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/ExpressionTypeExpression.java @@ -0,0 +1,75 @@ +/* + * 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. + * + * @see Data types + */ +public final class ExpressionTypeExpression extends JmespathExpression { + + private final JmespathExpression expression; + + public ExpressionTypeExpression(JmespathExpression expression) { + this(expression, 1, 1); + } + + public ExpressionTypeExpression(JmespathExpression expression, int line, int column) { + super(line, column); + this.expression = expression; + } + + @Override + public T accept(ExpressionVisitor visitor) { + return visitor.visitExpressionType(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 ExpressionTypeExpression)) { + return false; + } + ExpressionTypeExpression that = (ExpressionTypeExpression) 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..b65290a7739 --- /dev/null +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/FieldExpression.java @@ -0,0 +1,77 @@ +/* + * 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. + * + *

This AST node is created for identifiers. For example, + * {@code foo} creates a {@code FieldExpression}. + * + * @see Identifiers + */ +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..98f707e7cca --- /dev/null +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/FilterProjectionExpression.java @@ -0,0 +1,101 @@ +/* + * 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; + +/** + * 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; + 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..b13d828dda0 --- /dev/null +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/FlattenExpression.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.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. + * + * @see Flatten Operator + */ +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..d0bf7ab8cfc --- /dev/null +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/FunctionExpression.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.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. + * + * @see Function 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..d441c8630ed --- /dev/null +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/IndexExpression.java @@ -0,0 +1,78 @@ +/* + * 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 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 { + + 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..0c560bab841 --- /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; + } 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 = expectObjectValue(); + 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 expectObjectValue().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 = expectArrayValue(); + + 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 expectStringValue() { + 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 expectNumberValue() { + 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 expectBooleanValue() { + 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 expectArrayValue() { + 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 expectObjectValue() { + 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: // references are always true + return true; + case STRING: + return !expectStringValue().isEmpty(); + case ARRAY: + return !expectArrayValue().isEmpty(); + case OBJECT: + return !expectObjectValue().isEmpty(); + case BOOLEAN: + 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 new file mode 100644 index 00000000000..6489c07408e --- /dev/null +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/MultiSelectHashExpression.java @@ -0,0 +1,75 @@ +/* + * 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. + * + * @see MultiSelect Hash + */ +public final class MultiSelectHashExpression extends JmespathExpression { + + private final Map expressions; + + public MultiSelectHashExpression(Map expressions) { + this(expressions, 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..0ff7b7864b0 --- /dev/null +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/MultiSelectListExpression.java @@ -0,0 +1,75 @@ +/* + * 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. + * + * @see MultiSelect List + */ +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..f9aad195b1a --- /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 expression) { + this(expression, 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..3a71317f47a --- /dev/null +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/ObjectProjectionExpression.java @@ -0,0 +1,45 @@ +/* + * 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. + * + *

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 { + + 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..e13b27f4c22 --- /dev/null +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/OrExpression.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; + +import software.amazon.smithy.jmespath.ExpressionVisitor; +import software.amazon.smithy.jmespath.JmespathExpression; + +/** + * Or expression that returns the expression that returns a truthy value. + * + * @see Or Expressions + */ +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..363ff3f35a5 --- /dev/null +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/ProjectionExpression.java @@ -0,0 +1,44 @@ +/* + * 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. + * + *

This AST node is created when parsing expressions like {@code [*]}, + * {@code []}, and {@code [1:1]}. + * + * @see Wildcard Expressions + */ +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..090047530e2 --- /dev/null +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/SliceExpression.java @@ -0,0 +1,85 @@ +/* + * 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. + * + * @see Slices + */ +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..63ed814ded8 --- /dev/null +++ b/smithy-jmespath/src/main/java/software/amazon/smithy/jmespath/ast/Subexpression.java @@ -0,0 +1,44 @@ +/* + * 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. + * + *

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 { + + 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..0f057b960be --- /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.expectStringValue(), 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.expectStringValue(), 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.expectStringValue(), 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.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)); + + 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.expectArrayValue(), 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.expectObjectValue(); + 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.expectObjectValue().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.expectStringValue(), 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.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.expectStringValue(), 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.expectStringValue(), 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.expectStringValue(), 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.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.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.expectNumberValue().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.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.expectStringValue(), 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.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.expectNumberValue().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.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.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.expectStringValue(), 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.expectStringValue(), 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..021758f82fa --- /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.ComparatorExpression; +import software.amazon.smithy.jmespath.ast.CurrentExpression; +import software.amazon.smithy.jmespath.ast.ExpressionTypeExpression; +import software.amazon.smithy.jmespath.ast.FieldExpression; +import software.amazon.smithy.jmespath.ast.FilterProjectionExpression; +import software.amazon.smithy.jmespath.ast.FlattenExpression; +import software.amazon.smithy.jmespath.ast.FunctionExpression; +import software.amazon.smithy.jmespath.ast.IndexExpression; +import software.amazon.smithy.jmespath.ast.LiteralExpression; +import software.amazon.smithy.jmespath.ast.MultiSelectHashExpression; +import software.amazon.smithy.jmespath.ast.MultiSelectListExpression; +import software.amazon.smithy.jmespath.ast.NotExpression; +import software.amazon.smithy.jmespath.ast.ObjectProjectionExpression; +import software.amazon.smithy.jmespath.ast.OrExpression; +import software.amazon.smithy.jmespath.ast.ProjectionExpression; +import software.amazon.smithy.jmespath.ast.SliceExpression; +import software.amazon.smithy.jmespath.ast.Subexpression; + +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 ExpressionTypeExpression( + 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 ComparatorExpression( + 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 ComparatorExpression( + 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 ComparatorExpression( + 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 ComparatorExpression( + 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..bd419f69f4f --- /dev/null +++ b/smithy-jmespath/src/test/java/software/amazon/smithy/jmespath/RunnerTest.java @@ -0,0 +1,58 @@ +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); + } + } + } 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.expectStringValue()) + .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..2bbc6fdf77e --- /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 (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..41aa5e2bc0e --- /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::expectStringValue); + } + + @Test + public void getsAsString() { + LiteralExpression node = new LiteralExpression("foo"); + + node.expectStringValue(); + 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::expectArrayValue); + } + + @Test + public void getsAsArray() { + LiteralExpression node = new LiteralExpression(Collections.emptyList()); + + node.expectArrayValue(); + 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::expectNumberValue); + } + + @Test + public void getsAsNumber() { + LiteralExpression node = new LiteralExpression(10); + + node.expectNumberValue(); + 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::expectBooleanValue); + } + + @Test + public void getsAsBoolean() { + LiteralExpression node = new LiteralExpression(true); + + node.expectBooleanValue(); + assertThat(node.isBooleanValue(), is(true)); + assertThat(node.getType(), equalTo(RuntimeType.BOOLEAN)); + } + + @Test + public void getsAsBoxedBoolean() { + LiteralExpression node = new LiteralExpression(new Boolean(true)); + + node.expectBooleanValue(); + assertThat(node.isBooleanValue(), is(true)); + } + + @Test + public void throwsWhenNotMap() { + LiteralExpression node = new LiteralExpression("hi"); + + Assertions.assertThrows(JmespathException.class, node::expectObjectValue); + } + + @Test + public void getsAsMap() { + LiteralExpression node = new LiteralExpression(Collections.emptyMap()); + + node.expectObjectValue(); + assertThat(node.isObjectValue(), is(true)); + assertThat(node.getType(), equalTo(RuntimeType.OBJECT)); + } + + @Test + public void expressionReferenceTypeIsExpref() { + assertThat(LiteralExpression.EXPREF.getType(), equalTo(RuntimeType.EXPRESSION)); + } + + @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]" 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..11f6c460a3e --- /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 an intermediate 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..2fbdaf40c99 --- /dev/null +++ b/smithy-waiters/src/main/java/software/amazon/smithy/waiters/Matcher.java @@ -0,0 +1,265 @@ +/* + * 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.Map; +import java.util.Objects; +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 visitInputOutput(InputOutputMember inputOutputPath); + + T visitSuccess(SuccessMember success); + + T visitErrorType(ErrorTypeMember errorType); + + 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 "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()); + 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 InputOutputMember extends PathMatcherMember { + public InputOutputMember(PathMatcher value) { + super("inputOutput", value); + } + + @Override + public U accept(Visitor visitor) { + return visitor.visitInputOutput(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); + } + } +} 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..e1c6e9075af --- /dev/null +++ b/smithy-waiters/src/main/java/software/amazon/smithy/waiters/ModelRuntimeTypeGenerator.java @@ -0,0 +1,270 @@ +/* + * 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 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); + 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 range 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..9b6dcd4a633 --- /dev/null +++ b/smithy-waiters/src/main/java/software/amazon/smithy/waiters/PathComparator.java @@ -0,0 +1,71 @@ +/* + * 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 PathMatcher. + */ +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 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 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) { + 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..e3012d8e6ec --- /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 PathMatcher from. + * @return Returns the created PathMatcher. + * @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..078e4e5c753 --- /dev/null +++ b/smithy-waiters/src/main/java/software/amazon/smithy/waiters/WaiterMatcherValidator.java @@ -0,0 +1,207 @@ +/* + * 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.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; +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(createCurrentNodeFromShape(struct), outputPath.getValue()); + } + return events; + } + + @Override + 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; + } + + @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 visitUnknown(Matcher.UnknownMember unknown) { + // This is validated by model validation. No need to do more here. + return events; + } + + private void validatePathMatcher(LiteralExpression input, PathMatcher pathMatcher) { + RuntimeType returnType = validatePath(input, 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 RuntimeType validatePath(LiteralExpression input, String path) { + try { + JmespathExpression expression = JmespathExpression.parse(path); + LinterResult result = expression.lint(input); + 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; + } + } + + 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 + ? 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..3546218374b --- /dev/null +++ b/smithy-waiters/src/main/resources/META-INF/smithy/waiters.smithy @@ -0,0 +1,152 @@ +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 120 if not specified (or, 2 minutes). 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 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 + /// 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, +} + +@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 PathMatcher. +@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." + } +]) +@private +string PathComparator 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/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-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-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-output-structure-member-access.errors b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-output-structure-member-access.errors new file mode 100644 index 00000000000..11d3ad6e1dd --- /dev/null +++ b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-output-structure-member-access.errors @@ -0,0 +1 @@ +[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-output-structure-member-access.smithy b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-output-structure-member-access.smithy new file mode 100644 index 00000000000..aacec15a31b --- /dev/null +++ b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/invalid-output-structure-member-access.smithy @@ -0,0 +1,32 @@ +namespace smithy.example + +use smithy.waiters#waitable + +@waitable( + A: { + "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/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/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..5fa17e81123 --- /dev/null +++ b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/not-uppercamelcase.smithy @@ -0,0 +1,28 @@ +namespace smithy.example + +use smithy.waiters#waitable + +@waitable( + thingNotExists: { + "documentation": "Something", + "acceptors": [ + { + "state": "success", + "matcher": { + "output": { + "path": "baz == 'hi'", + "comparator": "booleanEquals", + "expected": "true" + } + } + } + ] + } +) +operation A { + output: AOutput, +} + +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/output-on-bad-shapes.smithy b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/output-on-bad-shapes.smithy new file mode 100644 index 00000000000..1b0e401aa7a --- /dev/null +++ b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/output-on-bad-shapes.smithy @@ -0,0 +1,21 @@ +namespace smithy.example + +use smithy.waiters#waitable + +@waitable( + A: { + "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/valid-inputoutput.errors b/smithy-waiters/src/test/resources/software/amazon/smithy/waiters/errorfiles/valid-inputoutput.errors new file mode 100644 index 00000000000..e69de29bb2d 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-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 +}