diff --git a/.changeset/large-keys-protect.md b/.changeset/large-keys-protect.md new file mode 100644 index 00000000000..d310d004410 --- /dev/null +++ b/.changeset/large-keys-protect.md @@ -0,0 +1,5 @@ +--- +"@smithy/util-endpoints": patch +--- + +Migrate util-endpoints package diff --git a/packages/util-endpoints/CHANGELOG.md b/packages/util-endpoints/CHANGELOG.md new file mode 100644 index 00000000000..420e6f23d0e --- /dev/null +++ b/packages/util-endpoints/CHANGELOG.md @@ -0,0 +1 @@ +# Change Log diff --git a/packages/util-endpoints/LICENSE b/packages/util-endpoints/LICENSE new file mode 100644 index 00000000000..a1895fac30d --- /dev/null +++ b/packages/util-endpoints/LICENSE @@ -0,0 +1,201 @@ +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2021 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. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License 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. \ No newline at end of file diff --git a/packages/util-endpoints/README.md b/packages/util-endpoints/README.md new file mode 100644 index 00000000000..85d60b31c9b --- /dev/null +++ b/packages/util-endpoints/README.md @@ -0,0 +1,10 @@ +# @smithy/util-endpoints + +[![NPM version](https://img.shields.io/npm/v/@smithy/util-endpoints/latest.svg)](https://www.npmjs.com/package/@smithy/util-endpoints) +[![NPM downloads](https://img.shields.io/npm/dm/@smithy/util-endpoints.svg)](https://www.npmjs.com/package/@smithy/util-endpoints) + +> An internal package + +## Usage + +You probably shouldn't, at least directly. diff --git a/packages/util-endpoints/jest.config.integ.js b/packages/util-endpoints/jest.config.integ.js new file mode 100644 index 00000000000..58cf5258099 --- /dev/null +++ b/packages/util-endpoints/jest.config.integ.js @@ -0,0 +1,4 @@ +module.exports = { + preset: "ts-jest", + testMatch: ["**/*.integ.spec.ts"], +}; diff --git a/packages/util-endpoints/jest.config.js b/packages/util-endpoints/jest.config.js new file mode 100644 index 00000000000..a8d1c2e4991 --- /dev/null +++ b/packages/util-endpoints/jest.config.js @@ -0,0 +1,5 @@ +const base = require("../../jest.config.base.js"); + +module.exports = { + ...base, +}; diff --git a/packages/util-endpoints/package.json b/packages/util-endpoints/package.json new file mode 100644 index 00000000000..9e635b40c8f --- /dev/null +++ b/packages/util-endpoints/package.json @@ -0,0 +1,67 @@ +{ + "name": "@smithy/util-endpoints", + "version": "1.0.0", + "description": "Utilities to help with endpoint resolution.", + "main": "./dist-cjs/index.js", + "module": "./dist-es/index.js", + "scripts": { + "build": "concurrently 'yarn:build:cjs' 'yarn:build:es' 'yarn:build:types && yarn build:types:downlevel'", + "build:cjs": "yarn g:tsc -p tsconfig.cjs.json", + "build:es": "yarn g:tsc -p tsconfig.es.json", + "build:types": "yarn g:tsc -p tsconfig.types.json", + "build:types:downlevel": "downlevel-dts dist-types dist-types/ts3.4", + "stage-release": "rimraf ./.release && yarn pack && mkdir ./.release && tar zxvf ./package.tgz --directory ./.release && rm ./package.tgz", + "clean": "rimraf ./dist-* && rimraf *.tsbuildinfo", + "lint": "eslint -c ../../.eslintrc.js \"src/**/*.ts\"", + "format": "prettier --config ../../prettier.config.js --ignore-path ../.prettierignore --write \"**/*.{ts,md,json}\"", + "test": "yarn g:jest", + "test:integration": "yarn g:jest --config jest.config.integ.js" + }, + "keywords": [ + "endpoint" + ], + "author": { + "name": "AWS SDK for JavaScript Team", + "url": "https://aws.amazon.com/javascript/" + }, + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "workspace:^", + "@smithy/types": "workspace:^", + "tslib": "^2.5.0" + }, + "devDependencies": { + "@tsconfig/recommended": "1.0.1", + "@types/node": "^14.14.31", + "concurrently": "7.0.0", + "downlevel-dts": "0.10.1", + "rimraf": "3.0.2", + "typedoc": "0.23.23" + }, + "types": "./dist-types/index.d.ts", + "engines": { + "node": ">= 14.0.0" + }, + "typesVersions": { + "<4.0": { + "types/*": [ + "types/ts3.4/*" + ] + } + }, + "files": [ + "dist-*/**" + ], + "homepage": "https://github.com/awslabs/smithy-typescript/tree/master/packages/util-endpoints", + "repository": { + "type": "git", + "url": "https://github.com/awslabs/smithy-typescript.git", + "directory": "packages/util-endpoints" + }, + "typedoc": { + "entryPoint": "src/index.ts" + }, + "publishConfig": { + "directory": ".release/package" + } +} diff --git a/packages/util-endpoints/src/__mocks__/test-cases/aws-region.json b/packages/util-endpoints/src/__mocks__/test-cases/aws-region.json new file mode 100644 index 00000000000..940cdfac90e --- /dev/null +++ b/packages/util-endpoints/src/__mocks__/test-cases/aws-region.json @@ -0,0 +1,32 @@ +{ + "testCases": [ + { + "documentation": "basic region templating", + "params": { + "Region": "us-east-1" + }, + "expect": { + "endpoint": { + "url": "https://us-east-1.amazonaws.com", + "properties": { + "authSchemes": [ + { + "name": "sigv4", + "signingRegion": "us-east-1", + "signingName": "serviceName" + } + ] + } + } + } + }, + { + "documentation": "test case where region is unset", + "params": {}, + "expect": { + "error": "Region must be set to resolve a valid endpoint" + } + } + ], + "version": "1.4" +} diff --git a/packages/util-endpoints/src/__mocks__/test-cases/default-values.json b/packages/util-endpoints/src/__mocks__/test-cases/default-values.json new file mode 100644 index 00000000000..46630aba9cd --- /dev/null +++ b/packages/util-endpoints/src/__mocks__/test-cases/default-values.json @@ -0,0 +1,45 @@ +{ + "testCases": [ + { + "documentation": "default endpoint", + "params": {}, + "expect": { + "endpoint": { + "url": "https://fips.us-west-5.amazonaws.com" + } + } + }, + { + "documentation": "test case where FIPS is disabled", + "params": { + "UseFips": false + }, + "expect": { + "error": "UseFips = false" + } + }, + { + "documentation": "test case where FIPS is enabled explicitly", + "params": { + "UseFips": true + }, + "expect": { + "endpoint": { + "url": "https://fips.us-west-5.amazonaws.com" + } + } + }, + { + "documentation": "defaults can be overridden", + "params": { + "Region": "us-east-1" + }, + "expect": { + "endpoint": { + "url": "https://fips.us-east-1.amazonaws.com" + } + } + } + ], + "version": "1.4" +} diff --git a/packages/util-endpoints/src/__mocks__/test-cases/headers.json b/packages/util-endpoints/src/__mocks__/test-cases/headers.json new file mode 100644 index 00000000000..ee25e4914a4 --- /dev/null +++ b/packages/util-endpoints/src/__mocks__/test-cases/headers.json @@ -0,0 +1,20 @@ +{ + "version": "1.4", + "testCases": [ + { + "documentation": "header set to region", + "params": { + "Region": "us-east-1" + }, + "expect": { + "endpoint": { + "url": "https://us-east-1.amazonaws.com", + "headers": { + "x-amz-region": ["us-east-1"], + "x-amz-multi": ["*", "us-east-1"] + } + } + } + } + ] +} diff --git a/packages/util-endpoints/src/__mocks__/test-cases/local-region-override.json b/packages/util-endpoints/src/__mocks__/test-cases/local-region-override.json new file mode 100644 index 00000000000..50339efc24c --- /dev/null +++ b/packages/util-endpoints/src/__mocks__/test-cases/local-region-override.json @@ -0,0 +1,27 @@ +{ + "testCases": [ + { + "documentation": "local region override", + "params": { + "Region": "local" + }, + "expect": { + "endpoint": { + "url": "http://localhost:8080" + } + } + }, + { + "documentation": "standard region templated", + "params": { + "Region": "us-east-2" + }, + "expect": { + "endpoint": { + "url": "https://us-east-2.someservice.amazonaws.com" + } + } + } + ], + "version": "1.4" +} diff --git a/packages/util-endpoints/src/__mocks__/test-cases/parse-url.json b/packages/util-endpoints/src/__mocks__/test-cases/parse-url.json new file mode 100644 index 00000000000..58119a37373 --- /dev/null +++ b/packages/util-endpoints/src/__mocks__/test-cases/parse-url.json @@ -0,0 +1,154 @@ +{ + "version": "1.4", + "testCases": [ + { + "documentation": "simple URL parsing", + "params": { + "Endpoint": "https://authority.com/custom-path" + }, + "expect": { + "endpoint": { + "url": "https://https-authority.com.example.com/path-is/custom-path" + } + } + }, + { + "documentation": "empty path no slash", + "params": { + "Endpoint": "https://authority.com" + }, + "expect": { + "endpoint": { + "url": "https://https-authority.com-nopath.example.com" + } + } + }, + { + "documentation": "empty path with slash", + "params": { + "Endpoint": "https://authority.com/" + }, + "expect": { + "endpoint": { + "url": "https://https-authority.com-nopath.example.com" + } + } + }, + { + "documentation": "authority with port", + "params": { + "Endpoint": "https://authority.com:8000/port" + }, + "expect": { + "endpoint": { + "url": "https://authority.com:8000/uri-with-port" + } + } + }, + { + "documentation": "http schemes", + "params": { + "Endpoint": "http://authority.com:8000/port" + }, + "expect": { + "endpoint": { + "url": "http://authority.com:8000/uri-with-port" + } + } + }, + { + "documentation": "arbitrary schemes are not supported", + "params": { + "Endpoint": "acbd://example.com" + }, + "expect": { + "error": "endpoint was invalid" + } + }, + { + "documentation": "host labels are not validated", + "params": { + "Endpoint": "http://99_ab.com" + }, + "expect": { + "endpoint": { + "url": "http://http-99_ab.com-nopath.example.com" + } + } + }, + { + "documentation": "host labels are not validated", + "params": { + "Endpoint": "http://99_ab-.com" + }, + "expect": { + "endpoint": { + "url": "http://http-99_ab-.com-nopath.example.com" + } + } + }, + { + "documentation": "invalid URL", + "params": { + "Endpoint": "http://abc.com:a/foo" + }, + "expect": { + "error": "endpoint was invalid" + }, + "skip": true + }, + { + "documentation": "IP Address", + "params": { + "Endpoint": "http://192.168.1.1/foo/" + }, + "expect": { + "endpoint": { + "url": "http://192.168.1.1/foo/is-ip-addr" + } + } + }, + { + "documentation": "IP Address with port", + "params": { + "Endpoint": "http://192.168.1.1:1234/foo/" + }, + "expect": { + "endpoint": { + "url": "http://192.168.1.1:1234/foo/is-ip-addr" + } + } + }, + { + "documentation": "IPv6 Address", + "params": { + "Endpoint": "https://[2001:db8:85a3:8d3:1319:8a2e:370:7348]:443" + }, + "expect": { + "endpoint": { + "url": "https://[2001:db8:85a3:8d3:1319:8a2e:370:7348]:443/is-ip-addr" + } + } + }, + { + "documentation": "weird DNS name", + "params": { + "Endpoint": "https://999.999.abc.blah" + }, + "expect": { + "endpoint": { + "url": "https://https-999.999.abc.blah-nopath.example.com" + } + } + }, + { + "documentation": "query in resolved endpoint is not supported", + "params": { + "Endpoint": "https://example.com/path?query1=foo" + }, + "expect": { + "error": "endpoint was invalid" + } + } + ] +} diff --git a/packages/util-endpoints/src/__mocks__/test-cases/substring.json b/packages/util-endpoints/src/__mocks__/test-cases/substring.json new file mode 100644 index 00000000000..9607f1f170e --- /dev/null +++ b/packages/util-endpoints/src/__mocks__/test-cases/substring.json @@ -0,0 +1,155 @@ +{ + "testCases": [ + { + "documentation": "substring when string is long enough", + "params": { + "TestCaseId": "1", + "Input": "abcdefg" + }, + "expect": { + "error": "The value is: `abcd`" + } + }, + { + "documentation": "substring when string is exactly the right length", + "params": { + "TestCaseId": "1", + "Input": "abcd" + }, + "expect": { + "error": "The value is: `abcd`" + } + }, + { + "documentation": "substring when string is too short", + "params": { + "TestCaseId": "1", + "Input": "abc" + }, + "expect": { + "error": "No tests matched" + } + }, + { + "documentation": "substring when string is too short", + "params": { + "TestCaseId": "1", + "Input": "" + }, + "expect": { + "error": "No tests matched" + } + }, + { + "documentation": "substring on wide characters (ensure that unicode code points are properly counted)", + "params": { + "TestCaseId": "1", + "Input": "\ufdfd" + }, + "expect": { + "error": "No tests matched" + } + }, + { + "documentation": "substring when string is long enough", + "params": { + "TestCaseId": "2", + "Input": "abcdefg" + }, + "expect": { + "error": "The value is: `defg`" + } + }, + { + "documentation": "substring when string is exactly the right length", + "params": { + "TestCaseId": "2", + "Input": "defg" + }, + "expect": { + "error": "The value is: `defg`" + } + }, + { + "documentation": "substring when string is too short", + "params": { + "TestCaseId": "2", + "Input": "abc" + }, + "expect": { + "error": "No tests matched" + } + }, + { + "documentation": "substring when string is too short", + "params": { + "TestCaseId": "2", + "Input": "" + }, + "expect": { + "error": "No tests matched" + } + }, + { + "documentation": "substring on wide characters (ensure that unicode code points are properly counted)", + "params": { + "TestCaseId": "2", + "Input": "\ufdfd" + }, + "expect": { + "error": "No tests matched" + } + }, + { + "documentation": "substring when string is longer", + "params": { + "TestCaseId": "3", + "Input": "defg" + }, + "expect": { + "error": "The value is: `ef`" + } + }, + { + "documentation": "substring when string is exact length", + "params": { + "TestCaseId": "3", + "Input": "def" + }, + "expect": { + "error": "The value is: `ef`" + } + }, + { + "documentation": "substring when string is too short", + "params": { + "TestCaseId": "3", + "Input": "ab" + }, + "expect": { + "error": "No tests matched" + } + }, + { + "documentation": "substring when string is too short", + "params": { + "TestCaseId": "3", + "Input": "" + }, + "expect": { + "error": "No tests matched" + } + }, + { + "documentation": "substring on wide characters (ensure that unicode code points are properly counted)", + "params": { + "TestCaseId": "3", + "Input": "\ufdfd" + }, + "expect": { + "error": "No tests matched" + } + } + ], + "version": "1.4" +} diff --git a/packages/util-endpoints/src/__mocks__/test-cases/uri-encode.json b/packages/util-endpoints/src/__mocks__/test-cases/uri-encode.json new file mode 100644 index 00000000000..e0952303b23 --- /dev/null +++ b/packages/util-endpoints/src/__mocks__/test-cases/uri-encode.json @@ -0,0 +1,75 @@ +{ + "testCases": [ + { + "documentation": "uriEncode when the string has nothing to encode returns the input", + "params": { + "TestCaseId": "1", + "Input": "abcdefg" + }, + "expect": { + "error": "The value is: `abcdefg`" + } + }, + { + "documentation": "uriEncode with single character to encode encodes only that character", + "params": { + "TestCaseId": "1", + "Input": "abc:defg" + }, + "expect": { + "error": "The value is: `abc%3Adefg`" + } + }, + { + "documentation": "uriEncode with all ASCII characters to encode encodes all characters", + "params": { + "TestCaseId": "1", + "Input": "/:,?#[]{}|@! $&'()*+;=%<>\"^`\\" + }, + "expect": { + "error": "The value is: `%2F%3A%2C%3F%23%5B%5D%7B%7D%7C%40%21%20%24%26%27%28%29%2A%2B%3B%3D%25%3C%3E%22%5E%60%5C`" + } + }, + { + "documentation": "uriEncode with ASCII characters that should not be encoded returns the input", + "params": { + "TestCaseId": "1", + "Input": "0123456789.underscore_dash-Tilda~" + }, + "expect": { + "error": "The value is: `0123456789.underscore_dash-Tilda~`" + } + }, + { + "documentation": "uriEncode encodes unicode characters", + "params": { + "TestCaseId": "1", + "Input": "\ud83d\ude39" + }, + "expect": { + "error": "The value is: `%F0%9F%98%B9`" + } + }, + { + "documentation": "uriEncode on all printable ASCII characters", + "params": { + "TestCaseId": "1", + "Input": " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~" + }, + "expect": { + "error": "The value is: `%20%21%22%23%24%25%26%27%28%29%2A%2B%2C-.%2F0123456789%3A%3B%3C%3D%3E%3F%40ABCDEFGHIJKLMNOPQRSTUVWXYZ%5B%5C%5D%5E_%60abcdefghijklmnopqrstuvwxyz%7B%7C%7D~`" + } + }, + { + "documentation": "uriEncode on an empty string", + "params": { + "TestCaseId": "1", + "Input": "" + }, + "expect": { + "error": "The value is: ``" + } + } + ], + "version": "1.4" +} diff --git a/packages/util-endpoints/src/__mocks__/test-cases/valid-hostlabel.json b/packages/util-endpoints/src/__mocks__/test-cases/valid-hostlabel.json new file mode 100644 index 00000000000..6785c8dc3ad --- /dev/null +++ b/packages/util-endpoints/src/__mocks__/test-cases/valid-hostlabel.json @@ -0,0 +1,47 @@ +{ + "testCases": [ + { + "documentation": "standard region is a valid hostlabel", + "params": { + "Region": "us-east-1" + }, + "expect": { + "endpoint": { + "url": "https://us-east-1.amazonaws.com" + } + } + }, + { + "documentation": "starting with a number is a valid hostlabel", + "params": { + "Region": "3aws4" + }, + "expect": { + "endpoint": { + "url": "https://3aws4.amazonaws.com" + } + } + }, + { + "documentation": "when there are dots, only match if subdomains are allowed", + "params": { + "Region": "part1.part2" + }, + "expect": { + "endpoint": { + "url": "https://part1.part2-subdomains.amazonaws.com" + } + } + }, + { + "documentation": "a space is never a valid hostlabel", + "params": { + "Region": "part1 part2" + }, + "expect": { + "error": "Invalid hostlabel" + } + } + ], + "version": "1.4" +} diff --git a/packages/util-endpoints/src/__mocks__/valid-rules/aws-region.json b/packages/util-endpoints/src/__mocks__/valid-rules/aws-region.json new file mode 100644 index 00000000000..2bb8e7fa644 --- /dev/null +++ b/packages/util-endpoints/src/__mocks__/valid-rules/aws-region.json @@ -0,0 +1,44 @@ +{ + "parameters": { + "Region": { + "type": "string", + "builtIn": "AWS::Region", + "documentation": "The region to dispatch this request, eg. `us-east-1`." + } + }, + "rules": [ + { + "documentation": "Template the region into the URI when region is set", + "conditions": [ + { + "fn": "isSet", + "argv": [ + { + "ref": "Region" + } + ] + } + ], + "endpoint": { + "url": "https://{Region}.amazonaws.com", + "properties": { + "authSchemes": [ + { + "name": "sigv4", + "signingName": "serviceName", + "signingRegion": "{Region}" + } + ] + } + }, + "type": "endpoint" + }, + { + "documentation": "fallback when region is unset", + "conditions": [], + "error": "Region must be set to resolve a valid endpoint", + "type": "error" + } + ], + "version": "1.3" +} diff --git a/packages/util-endpoints/src/__mocks__/valid-rules/default-values.json b/packages/util-endpoints/src/__mocks__/valid-rules/default-values.json new file mode 100644 index 00000000000..42c95b43c1f --- /dev/null +++ b/packages/util-endpoints/src/__mocks__/valid-rules/default-values.json @@ -0,0 +1,44 @@ +{ + "parameters": { + "Region": { + "type": "string", + "builtIn": "AWS::Region", + "documentation": "The region to dispatch this request, eg. `us-east-1`.", + "default": "us-west-5", + "required": true + }, + "UseFips": { + "type": "boolean", + "builtIn": "AWS::UseFIPS", + "default": true, + "required": true + } + }, + "rules": [ + { + "documentation": "Template the region into the URI when FIPS is enabled", + "conditions": [ + { + "fn": "booleanEquals", + "argv": [ + { + "ref": "UseFips" + }, + true + ] + } + ], + "endpoint": { + "url": "https://fips.{Region}.amazonaws.com" + }, + "type": "endpoint" + }, + { + "documentation": "error when fips is disabled", + "conditions": [], + "error": "UseFips = false", + "type": "error" + } + ], + "version": "1.3" +} diff --git a/packages/util-endpoints/src/__mocks__/valid-rules/headers.json b/packages/util-endpoints/src/__mocks__/valid-rules/headers.json new file mode 100644 index 00000000000..e15fc72c890 --- /dev/null +++ b/packages/util-endpoints/src/__mocks__/valid-rules/headers.json @@ -0,0 +1,39 @@ +{ + "parameters": { + "Region": { + "type": "string", + "builtIn": "AWS::Region", + "documentation": "The region to dispatch this request, eg. `us-east-1`." + } + }, + "rules": [ + { + "documentation": "Template the region into the URI when region is set", + "conditions": [ + { + "fn": "isSet", + "argv": [ + { + "ref": "Region" + } + ] + } + ], + "endpoint": { + "url": "https://{Region}.amazonaws.com", + "headers": { + "x-amz-region": ["{Region}"], + "x-amz-multi": ["*", "{Region}"] + } + }, + "type": "endpoint" + }, + { + "documentation": "fallback when region is unset", + "conditions": [], + "error": "Region must be set to resolve a valid endpoint", + "type": "error" + } + ], + "version": "1.3" +} diff --git a/packages/util-endpoints/src/__mocks__/valid-rules/local-region-override.json b/packages/util-endpoints/src/__mocks__/valid-rules/local-region-override.json new file mode 100644 index 00000000000..c7089032093 --- /dev/null +++ b/packages/util-endpoints/src/__mocks__/valid-rules/local-region-override.json @@ -0,0 +1,33 @@ +{ + "parameters": { + "Region": { + "type": "string", + "builtIn": "AWS::Region", + "required": true + } + }, + "rules": [ + { + "documentation": "override rule for the local pseduo region", + "conditions": [ + { + "fn": "stringEquals", + "argv": ["local", "{Region}"] + } + ], + "endpoint": { + "url": "http://localhost:8080" + }, + "type": "endpoint" + }, + { + "documentation": "base rule", + "conditions": [], + "endpoint": { + "url": "https://{Region}.someservice.amazonaws.com" + }, + "type": "endpoint" + } + ], + "version": "1.3" +} diff --git a/packages/util-endpoints/src/__mocks__/valid-rules/parse-url.json b/packages/util-endpoints/src/__mocks__/valid-rules/parse-url.json new file mode 100644 index 00000000000..760363b83c3 --- /dev/null +++ b/packages/util-endpoints/src/__mocks__/valid-rules/parse-url.json @@ -0,0 +1,94 @@ +{ + "version": "1.3", + "parameters": { + "Region": { + "type": "string", + "builtIn": "AWS::Region" + }, + "Endpoint": { + "type": "string" + } + }, + "rules": [ + { + "documentation": "endpoint is set and is a valid URL", + "conditions": [ + { + "fn": "isSet", + "argv": [ + { + "ref": "Endpoint" + } + ] + }, + { + "fn": "parseURL", + "argv": ["{Endpoint}"], + "assign": "url" + } + ], + "rules": [ + { + "conditions": [ + { + "fn": "booleanEquals", + "argv": [ + { + "fn": "getAttr", + "argv": [ + { + "ref": "url" + }, + "isIp" + ] + }, + true + ] + } + ], + "endpoint": { + "url": "{url#scheme}://{url#authority}{url#normalizedPath}is-ip-addr" + }, + "type": "endpoint" + }, + { + "conditions": [ + { + "fn": "stringEquals", + "argv": ["{url#path}", "/port"] + } + ], + "endpoint": { + "url": "{url#scheme}://{url#authority}/uri-with-port" + }, + "type": "endpoint" + }, + { + "conditions": [ + { + "fn": "stringEquals", + "argv": ["{url#normalizedPath}", "/"] + } + ], + "endpoint": { + "url": "https://{url#scheme}-{url#authority}-nopath.example.com" + }, + "type": "endpoint" + }, + { + "conditions": [], + "endpoint": { + "url": "https://{url#scheme}-{url#authority}.example.com/path-is{url#path}" + }, + "type": "endpoint" + } + ], + "type": "tree" + }, + { + "error": "endpoint was invalid", + "conditions": [], + "type": "error" + } + ] +} diff --git a/packages/util-endpoints/src/__mocks__/valid-rules/substring.json b/packages/util-endpoints/src/__mocks__/valid-rules/substring.json new file mode 100644 index 00000000000..e9b22143dc5 --- /dev/null +++ b/packages/util-endpoints/src/__mocks__/valid-rules/substring.json @@ -0,0 +1,71 @@ +{ + "parameters": { + "TestCaseId": { + "type": "string", + "required": true, + "documentation": "Test case id used to select the test case to use" + }, + "Input": { + "type": "string", + "required": true, + "documentation": "the input used to test substring" + } + }, + "rules": [ + { + "documentation": "Substring from beginning of input", + "conditions": [ + { + "fn": "stringEquals", + "argv": ["{TestCaseId}", "1"] + }, + { + "fn": "substring", + "argv": ["{Input}", 0, 4, false], + "assign": "output" + } + ], + "error": "The value is: `{output}`", + "type": "error" + }, + { + "documentation": "Substring from end of input", + "conditions": [ + { + "fn": "stringEquals", + "argv": ["{TestCaseId}", "2"] + }, + { + "fn": "substring", + "argv": ["{Input}", 0, 4, true], + "assign": "output" + } + ], + "error": "The value is: `{output}`", + "type": "error" + }, + { + "documentation": "Substring the middle of the string", + "conditions": [ + { + "fn": "stringEquals", + "argv": ["{TestCaseId}", "3"] + }, + { + "fn": "substring", + "argv": ["{Input}", 1, 3, false], + "assign": "output" + } + ], + "error": "The value is: `{output}`", + "type": "error" + }, + { + "documentation": "fallback when no tests match", + "conditions": [], + "error": "No tests matched", + "type": "error" + } + ], + "version": "1.3" +} diff --git a/packages/util-endpoints/src/__mocks__/valid-rules/uri-encode.json b/packages/util-endpoints/src/__mocks__/valid-rules/uri-encode.json new file mode 100644 index 00000000000..33cb6112ca8 --- /dev/null +++ b/packages/util-endpoints/src/__mocks__/valid-rules/uri-encode.json @@ -0,0 +1,39 @@ +{ + "version": "1.3", + "parameters": { + "TestCaseId": { + "type": "string", + "required": true, + "documentation": "Test case id used to select the test case to use" + }, + "Input": { + "type": "string", + "required": true, + "documentation": "the input used to test uriEncode" + } + }, + "rules": [ + { + "documentation": "uriEncode on input", + "conditions": [ + { + "fn": "stringEquals", + "argv": ["{TestCaseId}", "1"] + }, + { + "fn": "uriEncode", + "argv": ["{Input}"], + "assign": "output" + } + ], + "error": "The value is: `{output}`", + "type": "error" + }, + { + "documentation": "fallback when no tests match", + "conditions": [], + "error": "No tests matched", + "type": "error" + } + ] +} diff --git a/packages/util-endpoints/src/__mocks__/valid-rules/valid-hostlabel.json b/packages/util-endpoints/src/__mocks__/valid-rules/valid-hostlabel.json new file mode 100644 index 00000000000..d62e25af3ac --- /dev/null +++ b/packages/util-endpoints/src/__mocks__/valid-rules/valid-hostlabel.json @@ -0,0 +1,55 @@ +{ + "parameters": { + "Region": { + "type": "string", + "builtIn": "AWS::Region", + "required": true, + "documentation": "The region to dispatch this request, eg. `us-east-1`." + } + }, + "rules": [ + { + "documentation": "Template the region into the URI when region is set", + "conditions": [ + { + "fn": "isValidHostLabel", + "argv": [ + { + "ref": "Region" + }, + false + ] + } + ], + "endpoint": { + "url": "https://{Region}.amazonaws.com" + }, + "type": "endpoint" + }, + { + "documentation": "Template the region into the URI when region is set", + "conditions": [ + { + "fn": "isValidHostLabel", + "argv": [ + { + "ref": "Region" + }, + true + ] + } + ], + "endpoint": { + "url": "https://{Region}-subdomains.amazonaws.com" + }, + "type": "endpoint" + }, + { + "documentation": "Region was not a valid host label", + "conditions": [], + "error": "Invalid hostlabel", + "type": "error" + } + ], + "version": "1.3" +} diff --git a/packages/util-endpoints/src/debug/debugId.ts b/packages/util-endpoints/src/debug/debugId.ts new file mode 100644 index 00000000000..0d4e27e03b9 --- /dev/null +++ b/packages/util-endpoints/src/debug/debugId.ts @@ -0,0 +1 @@ +export const debugId = "endpoints"; diff --git a/packages/util-endpoints/src/debug/index.ts b/packages/util-endpoints/src/debug/index.ts new file mode 100644 index 00000000000..70d3b15c368 --- /dev/null +++ b/packages/util-endpoints/src/debug/index.ts @@ -0,0 +1,2 @@ +export * from "./debugId"; +export * from "./toDebugString"; diff --git a/packages/util-endpoints/src/debug/toDebugString.ts b/packages/util-endpoints/src/debug/toDebugString.ts new file mode 100644 index 00000000000..af46897d4d5 --- /dev/null +++ b/packages/util-endpoints/src/debug/toDebugString.ts @@ -0,0 +1,26 @@ +import { EndpointParameters, EndpointV2 } from "@smithy/types"; + +import { GetAttrValue } from "../lib"; +import { EndpointObject, FunctionObject, FunctionReturn } from "../types"; + +export function toDebugString(input: EndpointParameters): string; +export function toDebugString(input: EndpointV2): string; +export function toDebugString(input: GetAttrValue): string; +export function toDebugString(input: FunctionObject): string; +export function toDebugString(input: FunctionReturn): string; +export function toDebugString(input: EndpointObject): string; +export function toDebugString(input: any): string { + if (typeof input !== "object" || input == null) { + return input; + } + + if ("ref" in input) { + return `$${toDebugString(input.ref)}`; + } + + if ("fn" in input) { + return `${input.fn}(${(input.argv || []).map(toDebugString).join(", ")})`; + } + + return JSON.stringify(input, null, 2); +} diff --git a/packages/util-endpoints/src/getEndpointUrlConfig.spec.ts b/packages/util-endpoints/src/getEndpointUrlConfig.spec.ts new file mode 100644 index 00000000000..14555165088 --- /dev/null +++ b/packages/util-endpoints/src/getEndpointUrlConfig.spec.ts @@ -0,0 +1,91 @@ +import { getEndpointUrlConfig } from "./getEndpointUrlConfig"; + +const ENV_ENDPOINT_URL = "AWS_ENDPOINT_URL"; +const CONFIG_ENDPOINT_URL = "endpoint_url"; + +describe(getEndpointUrlConfig.name, () => { + const serviceId = "mockServiceId"; + const endpointUrlConfig = getEndpointUrlConfig(serviceId); + + const mockEndpoint = "https://mock-endpoint.com"; + const ORIGINAL_ENV = process.env; + + beforeEach(() => { + process.env = {}; + }); + + afterEach(() => { + process.env = ORIGINAL_ENV; + }); + + describe("environmentVariableSelector", () => { + beforeEach(() => { + process.env[ENV_ENDPOINT_URL] = mockEndpoint; + }); + + it.each([ + ["foo", `${ENV_ENDPOINT_URL}_FOO`], + ["foobar", `${ENV_ENDPOINT_URL}_FOOBAR`], + ["foo bar", `${ENV_ENDPOINT_URL}_FOO_BAR`], + ])("returns endpoint for '%s' from environment variable %s", (serviceId, envKey) => { + const serviceMockEndpoint = `${mockEndpoint}/${envKey}`; + process.env[envKey] = serviceMockEndpoint; + + const endpointUrlConfig = getEndpointUrlConfig(serviceId); + expect(endpointUrlConfig.environmentVariableSelector(process.env)).toEqual(serviceMockEndpoint); + }); + + it(`returns endpoint from environment variable ${ENV_ENDPOINT_URL}`, () => { + expect(endpointUrlConfig.environmentVariableSelector(process.env)).toEqual(mockEndpoint); + }); + + it("returns undefined, if endpoint not available in environment variables", () => { + process.env[ENV_ENDPOINT_URL] = undefined; + expect(endpointUrlConfig.environmentVariableSelector(process.env)).toBeUndefined(); + }); + }); + + describe("configFileSelector", () => { + const profile = { [CONFIG_ENDPOINT_URL]: mockEndpoint }; + + // ToDo: Enable once support for services section is added. + it.skip.each([ + ["foo", "foo"], + ["foobar", "foobar"], + ["foo bar", "foo_bar"], + ])("returns endpoint for '%s' from config file '%s'", (serviceId, serviceConfigId) => { + const serviceMockEndpoint = `${mockEndpoint}/${serviceConfigId}`; + const serviceSectionName = `services ${serviceConfigId}_dev`; + + const profileWithServices = { + ...profile, + services: serviceSectionName, + }; + const parsedIni = { + profileName: profileWithServices, + [serviceSectionName]: { + [serviceConfigId]: { + [CONFIG_ENDPOINT_URL]: serviceMockEndpoint, + }, + }, + }; + + // @ts-ignore + expect(endpointUrlConfig.environmentVariableSelector(profileWithServices, parsedIni)).toEqual( + serviceMockEndpoint + ); + }); + + it("returns endpoint from config file, if available", () => { + expect(endpointUrlConfig.configFileSelector(profile)).toEqual(mockEndpoint); + }); + + it("returns undefined, if endpoint not available in config", () => { + expect(endpointUrlConfig.environmentVariableSelector({})).toBeUndefined(); + }); + }); + + it("returns undefined by default", () => { + expect(endpointUrlConfig.default).toBeUndefined(); + }); +}); diff --git a/packages/util-endpoints/src/getEndpointUrlConfig.ts b/packages/util-endpoints/src/getEndpointUrlConfig.ts new file mode 100644 index 00000000000..e24c22a91c6 --- /dev/null +++ b/packages/util-endpoints/src/getEndpointUrlConfig.ts @@ -0,0 +1,36 @@ +import { LoadedConfigSelectors } from "@smithy/node-config-provider"; +import { IniSection } from "@smithy/types"; + +const ENV_ENDPOINT_URL = "AWS_ENDPOINT_URL"; +const CONFIG_ENDPOINT_URL = "endpoint_url"; + +export const getEndpointUrlConfig = (serviceId: string): LoadedConfigSelectors => ({ + environmentVariableSelector: (env: NodeJS.ProcessEnv) => { + // The value provided by a service-specific environment variable. + const serviceEndpointUrlSections = [ENV_ENDPOINT_URL, ...serviceId.split(" ").map((w) => w.toUpperCase())]; + const serviceEndpointUrl = env[serviceEndpointUrlSections.join("_")]; + if (serviceEndpointUrl) return serviceEndpointUrl; + + // The value provided by the global endpoint environment variable. + const endpointUrl = env[ENV_ENDPOINT_URL]; + if (endpointUrl) return endpointUrl; + + return undefined; + }, + + configFileSelector: (profile: IniSection) => { + // The value provided by a service-specific parameter from a services definition section + // referenced in a profile in the shared configuration file. + + // ToDo: profile is selected one. It does not have access to other 'services' section. + // The configFileSelector interface needs to be modified to pass ParsedIniData as optional second parameter. + + // The value provided by the global parameter from a profile in the shared configuration file. + const endpointUrl = profile[CONFIG_ENDPOINT_URL]; + if (endpointUrl) return endpointUrl; + + return undefined; + }, + + default: undefined, +}); diff --git a/packages/util-endpoints/src/index.ts b/packages/util-endpoints/src/index.ts new file mode 100644 index 00000000000..07c6fd3874e --- /dev/null +++ b/packages/util-endpoints/src/index.ts @@ -0,0 +1,5 @@ +export * from "./lib/isIpAddress"; +export * from "./lib/isValidHostLabel"; +export * from "./utils/customEndpointFunctions"; +export * from "./resolveEndpoint"; +export * from "./types"; diff --git a/packages/util-endpoints/src/lib/booleanEquals.spec.ts b/packages/util-endpoints/src/lib/booleanEquals.spec.ts new file mode 100644 index 00000000000..10a93d8c0dd --- /dev/null +++ b/packages/util-endpoints/src/lib/booleanEquals.spec.ts @@ -0,0 +1,13 @@ +import { booleanEquals } from "./booleanEquals"; + +describe(booleanEquals.name, () => { + it("returns true if values are equal", () => { + expect(booleanEquals(true, true)).toBe(true); + expect(booleanEquals(false, false)).toBe(true); + }); + + it("returns false if values are not equal", () => { + expect(booleanEquals(true, false)).toBe(false); + expect(booleanEquals(false, true)).toBe(false); + }); +}); diff --git a/packages/util-endpoints/src/lib/booleanEquals.ts b/packages/util-endpoints/src/lib/booleanEquals.ts new file mode 100644 index 00000000000..2fc984981b9 --- /dev/null +++ b/packages/util-endpoints/src/lib/booleanEquals.ts @@ -0,0 +1,5 @@ +/** + * Evaluates two boolean values value1 and value2 for equality and returns + * true if both values match. + */ +export const booleanEquals = (value1: boolean, value2: boolean): boolean => value1 === value2; diff --git a/packages/util-endpoints/src/lib/getAttr.spec.ts b/packages/util-endpoints/src/lib/getAttr.spec.ts new file mode 100644 index 00000000000..56bcf9abfc9 --- /dev/null +++ b/packages/util-endpoints/src/lib/getAttr.spec.ts @@ -0,0 +1,61 @@ +import { EndpointError } from "../types"; +import { getAttr } from "./getAttr"; +import { getAttrPathList } from "./getAttrPathList"; + +jest.mock("./getAttrPathList"); + +describe(getAttr.name, () => { + const testSuccess = (value: any, input: string, output: unknown, pathList: string[]) => { + (getAttrPathList as jest.Mock).mockReturnValueOnce(pathList); + expect(getAttr(value, input)).toEqual(output); + expect(getAttrPathList).toHaveBeenCalledWith(input); + }; + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("object", () => { + const mockObj = { Thing1: "foo", Thing2: ["index0", "index1"], Thing3: { SubThing: 42 } }; + + it.each([ + ["foo", "Thing1", ["Thing1"]], + ["index0", "Thing2[0]", ["Thing2", "0"]], + ["index1", "Thing2[1]", ["Thing2", "1"]], + [42, "Thing3.SubThing", ["Thing3", "SubThing"]], + ])("returns '%s' for '%s'", (output, input, pathList) => { + testSuccess(mockObj, input, output, pathList); + }); + }); + + describe("array", () => { + const mockArr = ["index0", "index1"]; + + it.each([ + [mockArr[0], "[0]", ["0"]], + [mockArr[1], "[1]", ["1"]], + ])("returns '%s' for '%s'", (output, input, pathList) => { + testSuccess(mockArr, input, output, pathList); + }); + }); + + it("rethrows error from getAttrPathList", () => { + const mockPath = "mockPath"; + const mockError = new Error("test"); + (getAttrPathList as jest.Mock).mockImplementationOnce(() => { + throw mockError; + }); + expect(() => getAttr({}, mockPath)).toThrow(mockError); + expect(getAttrPathList).toHaveBeenCalledWith(mockPath); + }); + + it("throws error if attribute parent is not defined", () => { + const mockPath = "foo.bar"; + const mockObj = { foo: "bar" }; + (getAttrPathList as jest.Mock).mockReturnValueOnce(mockPath.split(".")); + expect(() => getAttr(mockObj, mockPath)).toThrow( + new EndpointError(`Index 'bar' in '${mockPath}' not found in '${JSON.stringify(mockObj)}'`) + ); + expect(getAttrPathList).toHaveBeenCalledWith(mockPath); + }); +}); diff --git a/packages/util-endpoints/src/lib/getAttr.ts b/packages/util-endpoints/src/lib/getAttr.ts new file mode 100644 index 00000000000..4f3ce0764f8 --- /dev/null +++ b/packages/util-endpoints/src/lib/getAttr.ts @@ -0,0 +1,17 @@ +import { EndpointError } from "../types"; +import { getAttrPathList } from "./getAttrPathList"; + +export type GetAttrValue = string | boolean | { [key: string]: GetAttrValue } | Array; + +/** + * Returns value corresponding to pathing string for an array or object. + */ +export const getAttr = (value: GetAttrValue, path: string): GetAttrValue => + getAttrPathList(path).reduce((acc, index) => { + if (typeof acc !== "object") { + throw new EndpointError(`Index '${index}' in '${path}' not found in '${JSON.stringify(value)}'`); + } else if (Array.isArray(acc)) { + return acc[parseInt(index)]; + } + return acc[index]; + }, value); diff --git a/packages/util-endpoints/src/lib/getAttrPathList.spec.ts b/packages/util-endpoints/src/lib/getAttrPathList.spec.ts new file mode 100644 index 00000000000..4e6a03b4ebb --- /dev/null +++ b/packages/util-endpoints/src/lib/getAttrPathList.spec.ts @@ -0,0 +1,41 @@ +import { EndpointError } from "../types"; +import { getAttrPathList } from "./getAttrPathList"; + +describe(getAttrPathList.name, () => { + const testSuccess = (input: string, output: Array) => { + expect(getAttrPathList(input)).toEqual(output); + }; + + const testFail = (input: string, errorMsg: string) => { + expect(() => { + getAttrPathList(input); + }).toThrow(new EndpointError(errorMsg)); + }; + + it("returns top level key", () => { + testSuccess("foo", ["foo"]); + }); + + it("returns array with index", () => { + testSuccess("foo[0]", ["foo", "0"]); + }); + + it("returns index", () => { + testSuccess("[0]", ["0"]); + }); + + it("returns object key", () => { + testSuccess("foo.bar", ["foo", "bar"]); + }); + + it("throws error if array brackets don't end", () => { + const incompletePath = "foo[0"; + testFail(incompletePath, `Path: '${incompletePath}' does not end with ']'`); + }); + + it("throws error if array index is not integer", () => { + const invalidIndex = "bar"; + const invalidPath = `foo[${invalidIndex}]`; + testFail(invalidPath, `Invalid array index: '${invalidIndex}' in path: '${invalidPath}'`); + }); +}); diff --git a/packages/util-endpoints/src/lib/getAttrPathList.ts b/packages/util-endpoints/src/lib/getAttrPathList.ts new file mode 100644 index 00000000000..54e8e583615 --- /dev/null +++ b/packages/util-endpoints/src/lib/getAttrPathList.ts @@ -0,0 +1,33 @@ +import { EndpointError } from "../types"; + +/** + * Parses path as a getAttr expression, returning a list of strings. + */ +export const getAttrPathList = (path: string): Array => { + const parts = path.split("."); + const pathList = []; + + for (const part of parts) { + const squareBracketIndex = part.indexOf("["); + if (squareBracketIndex !== -1) { + if (part.indexOf("]") !== part.length - 1) { + throw new EndpointError(`Path: '${path}' does not end with ']'`); + } + + // Take the entire slice except for the last character (which is `]`) + const arrayIndex = part.slice(squareBracketIndex + 1, -1); + if (Number.isNaN(parseInt(arrayIndex))) { + throw new EndpointError(`Invalid array index: '${arrayIndex}' in path: '${path}'`); + } + + if (squareBracketIndex !== 0) { + pathList.push(part.slice(0, squareBracketIndex)); + } + pathList.push(arrayIndex); + } else { + pathList.push(part); + } + } + + return pathList; +}; diff --git a/packages/util-endpoints/src/lib/index.ts b/packages/util-endpoints/src/lib/index.ts new file mode 100644 index 00000000000..99a08449af2 --- /dev/null +++ b/packages/util-endpoints/src/lib/index.ts @@ -0,0 +1,9 @@ +export * from "./booleanEquals"; +export * from "./getAttr"; +export * from "./isSet"; +export * from "./isValidHostLabel"; +export * from "./not"; +export * from "./parseURL"; +export * from "./stringEquals"; +export * from "./substring"; +export * from "./uriEncode"; diff --git a/packages/util-endpoints/src/lib/isIpAddress.spec.ts b/packages/util-endpoints/src/lib/isIpAddress.spec.ts new file mode 100644 index 00000000000..b8fb77ef07c --- /dev/null +++ b/packages/util-endpoints/src/lib/isIpAddress.spec.ts @@ -0,0 +1,11 @@ +import { isIpAddress } from "./isIpAddress"; + +describe(isIpAddress.name, () => { + it.each([ + [false, "example.com"], + [true, "127.0.0.1"], + [true, "[fe80::1]"], + ])("returns %s for '%s'", (output, input) => { + expect(isIpAddress(input)).toEqual(output); + }); +}); diff --git a/packages/util-endpoints/src/lib/isIpAddress.ts b/packages/util-endpoints/src/lib/isIpAddress.ts new file mode 100644 index 00000000000..d125d898f67 --- /dev/null +++ b/packages/util-endpoints/src/lib/isIpAddress.ts @@ -0,0 +1,9 @@ +const IP_V4_REGEX = new RegExp( + `^(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)){3}$` +); + +/** + * Validates if the provided value is an IP address. + */ +export const isIpAddress = (value: string): boolean => + IP_V4_REGEX.test(value) || (value.startsWith("[") && value.endsWith("]")); diff --git a/packages/util-endpoints/src/lib/isSet.spec.ts b/packages/util-endpoints/src/lib/isSet.spec.ts new file mode 100644 index 00000000000..cc01b384097 --- /dev/null +++ b/packages/util-endpoints/src/lib/isSet.spec.ts @@ -0,0 +1,15 @@ +import { isSet } from "./isSet"; + +describe(isSet.name, () => { + it.each([null, undefined])("returns false for %s", (notSet) => { + expect(isSet(notSet)).toBe(false); + }); + + it.each([false, 0, -0, "", NaN])("returns true for falsy value %s", (falsyVal) => { + expect(isSet(falsyVal)).toBe(true); + }); + + it.each([true, 1, -1, "true", [], {}])("returns true for truthy value %s", (falsyVal) => { + expect(isSet(falsyVal)).toBe(true); + }); +}); diff --git a/packages/util-endpoints/src/lib/isSet.ts b/packages/util-endpoints/src/lib/isSet.ts new file mode 100644 index 00000000000..b7299e090f2 --- /dev/null +++ b/packages/util-endpoints/src/lib/isSet.ts @@ -0,0 +1,5 @@ +/** + * Evaluates whether a value is set (aka not null or undefined). + * Returns true if the value is set, otherwise returns false. + */ +export const isSet = (value: unknown) => value != null; diff --git a/packages/util-endpoints/src/lib/isValidHostLabel.spec.ts b/packages/util-endpoints/src/lib/isValidHostLabel.spec.ts new file mode 100644 index 00000000000..9a23d78a3bd --- /dev/null +++ b/packages/util-endpoints/src/lib/isValidHostLabel.spec.ts @@ -0,0 +1,47 @@ +import { isValidHostLabel } from "./isValidHostLabel"; + +describe(isValidHostLabel.name, () => { + const testCases: Array<[boolean, string]> = [ + [true, "01010"], + [true, "abc"], + [true, "A0c"], + [false, "A0c-"], + [false, "-A0c"], + [true, "A-0c"], + [false, "a".repeat(64)], + [true, "a".repeat(63)], + [false, ""], + [true, "a"], + [true, "0--0"], + ]; + + describe("test without allowSubDomains", () => { + it.each(testCases)("returns %s for host label '%s'", (output: boolean, input: string) => { + expect(isValidHostLabel(input)).toBe(output); + }); + }); + + describe("test with allowSubDomains", () => { + it.each([true, false])("tests for output: %s", (output: boolean) => { + const hostLabelToTest = testCases + .filter(([outputEntry]) => outputEntry === output) + .map(([, value]) => value) + .join("."); + expect(isValidHostLabel(hostLabelToTest, true)).toBe(output); + }); + + describe("returns false is any subdomain is invalid", () => { + const validHostLabel = testCases + .filter(([outputEntry]) => outputEntry === true) + .map(([, value]) => value) + .join("."); + + it.each(testCases.filter(([outputEntry]) => outputEntry === false).map(([, value]) => value))( + "subdomain: %s", + (invalidSubDomain: string) => { + expect(isValidHostLabel([validHostLabel, invalidSubDomain].join("."), true)).toBe(false); + } + ); + }); + }); +}); diff --git a/packages/util-endpoints/src/lib/isValidHostLabel.ts b/packages/util-endpoints/src/lib/isValidHostLabel.ts new file mode 100644 index 00000000000..69913cf68f3 --- /dev/null +++ b/packages/util-endpoints/src/lib/isValidHostLabel.ts @@ -0,0 +1,21 @@ +const VALID_HOST_LABEL_REGEX = new RegExp(`^(?!.*-$)(?!-)[a-zA-Z0-9-]{1,63}$`); + +/** + * Evaluates whether one or more string values are valid host labels per RFC 1123. + * + * If allowSubDomains is true, then the provided value may be zero or more dotted + * subdomains which are each validated per RFC 1123. + */ +export const isValidHostLabel = (value: string, allowSubDomains = false) => { + if (!allowSubDomains) { + return VALID_HOST_LABEL_REGEX.test(value); + } + + const labels = value.split("."); + for (const label of labels) { + if (!isValidHostLabel(label)) { + return false; + } + } + return true; +}; diff --git a/packages/util-endpoints/src/lib/not.spec.ts b/packages/util-endpoints/src/lib/not.spec.ts new file mode 100644 index 00000000000..ae52309ec51 --- /dev/null +++ b/packages/util-endpoints/src/lib/not.spec.ts @@ -0,0 +1,26 @@ +import { not } from "./not"; + +describe(not.name, () => { + it.each([ + [false, true], + [true, false], + ])("returns %s of boolean %s", (output, input) => { + expect(not(input)).toBe(output); + }); + + it.each([ + [true, null], + [true, undefined], + [true, 0], + [true, -0], + [true, NaN], + [false, 1], + [true, ""], + [false, "string"], + [false, []], + [false, {}], + ])("returns %s of non boolean %s", (output, input) => { + // @ts-expect-error: Argument of type is not assignable + expect(not(input)).toBe(output); + }); +}); diff --git a/packages/util-endpoints/src/lib/not.ts b/packages/util-endpoints/src/lib/not.ts new file mode 100644 index 00000000000..96aaf4ee869 --- /dev/null +++ b/packages/util-endpoints/src/lib/not.ts @@ -0,0 +1,5 @@ +/** + * Performs logical negation on the provided boolean value, + * returning the negated value. + */ +export const not = (value: boolean) => !value; diff --git a/packages/util-endpoints/src/lib/parseURL.spec.ts b/packages/util-endpoints/src/lib/parseURL.spec.ts new file mode 100644 index 00000000000..4e8b6d1f516 --- /dev/null +++ b/packages/util-endpoints/src/lib/parseURL.spec.ts @@ -0,0 +1,77 @@ +import { Endpoint, EndpointURL, EndpointURLScheme } from "@smithy/types"; + +import { parseURL } from "./parseURL"; + +describe(parseURL.name, () => { + const testCases: [string, EndpointURL][] = [ + [ + "https://example.com", + { scheme: EndpointURLScheme.HTTPS, authority: "example.com", path: "/", normalizedPath: "/", isIp: false }, + ], + [ + "http://example.com:80/foo/bar", + { + scheme: EndpointURLScheme.HTTP, + authority: "example.com:80", + path: "/foo/bar", + normalizedPath: "/foo/bar/", + isIp: false, + }, + ], + [ + "https://127.0.0.1", + { scheme: EndpointURLScheme.HTTPS, authority: "127.0.0.1", path: "/", normalizedPath: "/", isIp: true }, + ], + [ + "https://127.0.0.1:8443", + { scheme: EndpointURLScheme.HTTPS, authority: "127.0.0.1:8443", path: "/", normalizedPath: "/", isIp: true }, + ], + [ + "https://[fe80::1]", + { scheme: EndpointURLScheme.HTTPS, authority: "[fe80::1]", path: "/", normalizedPath: "/", isIp: true }, + ], + [ + "https://[fe80::1]:8443", + { scheme: EndpointURLScheme.HTTPS, authority: "[fe80::1]:8443", path: "/", normalizedPath: "/", isIp: true }, + ], + ]; + + it.each(testCases)("test '%s'", (input: string, output: EndpointURL) => { + expect(parseURL(input)).toEqual(output); + }); + + it("returns null for invalid scheme", () => { + expect(parseURL("ws://example.com")).toBeNull(); + }); + + it("returns null for URL with search params", () => { + expect(parseURL("https://example.com:8443?foo=bar")).toBeNull(); + }); + + it("returns null for invalid URL", () => { + expect(parseURL("invalid")).toBeNull(); + }); + + it.each(testCases)("test as URL '%s'", (input: string, output: EndpointURL) => { + const url = new URL(input); + expect(parseURL(url)).toEqual({ + ...output, + authority: url.hostname + (url.port ? `:${url.port}` : ""), + }); + }); + + it.each(testCases)("test as EndpointV1 '%s'", (input: string, output: EndpointURL) => { + const url = new URL(input); + const endpointV1: Endpoint = { + protocol: url.protocol, + hostname: url.hostname, + port: url.port ? Number(url.port) : undefined, + path: url.pathname, + }; + + expect(parseURL(endpointV1)).toEqual({ + ...output, + authority: url.hostname + (url.port ? `:${url.port}` : ""), + }); + }); +}); diff --git a/packages/util-endpoints/src/lib/parseURL.ts b/packages/util-endpoints/src/lib/parseURL.ts new file mode 100644 index 00000000000..95364ffe297 --- /dev/null +++ b/packages/util-endpoints/src/lib/parseURL.ts @@ -0,0 +1,66 @@ +import { Endpoint, EndpointURL, EndpointURLScheme } from "@smithy/types"; + +import { isIpAddress } from "./isIpAddress"; + +const DEFAULT_PORTS: Record = { + [EndpointURLScheme.HTTP]: 80, + [EndpointURLScheme.HTTPS]: 443, +}; + +/** + * Parses a string, URL, or Endpoint into it’s Endpoint URL components. + */ +export const parseURL = (value: string | URL | Endpoint): EndpointURL | null => { + const whatwgURL = (() => { + try { + if (value instanceof URL) { + return value; + } + if (typeof value === "object" && "hostname" in value) { + const { hostname, port, protocol = "", path = "", query = {} } = value as Endpoint; + const url = new URL(`${protocol}//${hostname}${port ? `:${port}` : ""}${path}`); + url.search = Object.entries(query) + .map(([k, v]) => `${k}=${v}`) + .join("&"); + return url; + } + return new URL(value); + } catch (error) { + return null; + } + })(); + + if (!whatwgURL) { + console.error(`Unable to parse ${JSON.stringify(value)} as a whatwg URL.`); + return null; + } + + const urlString = whatwgURL.href; + + const { host, hostname, pathname, protocol, search } = whatwgURL; + + if (search) { + return null; + } + + const scheme = protocol.slice(0, -1) as EndpointURLScheme; + if (!Object.values(EndpointURLScheme).includes(scheme)) { + return null; + } + + const isIp = isIpAddress(hostname); + + const inputContainsDefaultPort = + urlString.includes(`${host}:${DEFAULT_PORTS[scheme]}`) || + (typeof value === "string" && value.includes(`${host}:${DEFAULT_PORTS[scheme]}`)); + + const authority = `${host}${inputContainsDefaultPort ? `:${DEFAULT_PORTS[scheme]}` : ``}`; + + return { + scheme, + authority, + path: pathname, + normalizedPath: pathname.endsWith("/") ? pathname : `${pathname}/`, + isIp, + }; +}; diff --git a/packages/util-endpoints/src/lib/stringEquals.spec.ts b/packages/util-endpoints/src/lib/stringEquals.spec.ts new file mode 100644 index 00000000000..aef010a4766 --- /dev/null +++ b/packages/util-endpoints/src/lib/stringEquals.spec.ts @@ -0,0 +1,11 @@ +import { stringEquals } from "./stringEquals"; + +describe(stringEquals.name, () => { + it("returns true if values are equal", () => { + expect(stringEquals("foo", "foo")).toBe(true); + }); + + it("returns false if values are not equal", () => { + expect(stringEquals("foo", "bar")).toBe(false); + }); +}); diff --git a/packages/util-endpoints/src/lib/stringEquals.ts b/packages/util-endpoints/src/lib/stringEquals.ts new file mode 100644 index 00000000000..2259df689fa --- /dev/null +++ b/packages/util-endpoints/src/lib/stringEquals.ts @@ -0,0 +1,5 @@ +/** + * Evaluates two string values value1 and value2 for equality and returns + * true if both values match. + */ +export const stringEquals = (value1: string, value2: string): boolean => value1 === value2; diff --git a/packages/util-endpoints/src/lib/substring.spec.ts b/packages/util-endpoints/src/lib/substring.spec.ts new file mode 100644 index 00000000000..9246fef8685 --- /dev/null +++ b/packages/util-endpoints/src/lib/substring.spec.ts @@ -0,0 +1,22 @@ +import { substring } from "./substring"; + +describe(substring.name, () => { + describe("returns undefined", () => { + it("when start >= stop", () => { + expect(substring("", 0, 0, false)).toBeNull(); + expect(substring("", 1, 0, false)).toBeNull(); + }); + + it("when input.length < stop", () => { + expect(substring("", 0, 1, false)).toBeNull(); + }); + }); + + it("returns substring", () => { + expect(substring("abcde", 0, 3, false)).toBe("abc"); + }); + + it("returns substring with reverse=true", () => { + expect(substring("abcde", 0, 3, true)).toBe("cde"); + }); +}); diff --git a/packages/util-endpoints/src/lib/substring.ts b/packages/util-endpoints/src/lib/substring.ts new file mode 100644 index 00000000000..62e46bb33c5 --- /dev/null +++ b/packages/util-endpoints/src/lib/substring.ts @@ -0,0 +1,15 @@ +/** + * Computes the substring of a given string, conditionally indexing from the end of the string. + * When the string is long enough to fully include the substring, return the substring. + * Otherwise, return None. The start index is inclusive and the stop index is exclusive. + * The length of the returned string will always be stop-start. + */ +export const substring = (input: string, start: number, stop: number, reverse: boolean): string | null => { + if (start >= stop || input.length < stop) { + return null; + } + if (!reverse) { + return input.substring(start, stop); + } + return input.substring(input.length - stop, input.length - start); +}; diff --git a/packages/util-endpoints/src/lib/uriEncode.spec.ts b/packages/util-endpoints/src/lib/uriEncode.spec.ts new file mode 100644 index 00000000000..0158cef63bc --- /dev/null +++ b/packages/util-endpoints/src/lib/uriEncode.spec.ts @@ -0,0 +1,11 @@ +import { uriEncode } from "./uriEncode"; + +describe(uriEncode.name, () => { + it.each([ + [`;,/?:@&=+$#`, `%3B%2C%2F%3F%3A%40%26%3D%2B%24%23`], // Reserved characters + [`!*'()`, `%21%2A%27%28%29`], // Specially escaped characters + [` `, `%20`], // Space + ])("encodes '%s' as '%s'", (input, output) => { + expect(uriEncode(input)).toStrictEqual(output); + }); +}); diff --git a/packages/util-endpoints/src/lib/uriEncode.ts b/packages/util-endpoints/src/lib/uriEncode.ts new file mode 100644 index 00000000000..afa8a3036bb --- /dev/null +++ b/packages/util-endpoints/src/lib/uriEncode.ts @@ -0,0 +1,5 @@ +/** + * Performs percent-encoding per RFC3986 section 2.1 + */ +export const uriEncode = (value: string) => + encodeURIComponent(value).replace(/[!*'()]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`); diff --git a/packages/util-endpoints/src/resolveEndpoint.integ.spec.ts b/packages/util-endpoints/src/resolveEndpoint.integ.spec.ts new file mode 100644 index 00000000000..133d973f6c1 --- /dev/null +++ b/packages/util-endpoints/src/resolveEndpoint.integ.spec.ts @@ -0,0 +1,47 @@ +import { existsSync, readdirSync } from "fs"; +import { resolve } from "path"; + +import { resolveEndpoint } from "./resolveEndpoint"; +import { EndpointError } from "./types"; + +describe(resolveEndpoint.name, () => { + const mocksDir = resolve(__dirname, "__mocks__"); + const rulesDir = resolve(mocksDir, "valid-rules"); + const testCasesDir = resolve(mocksDir, "test-cases"); + + const validRules = readdirSync(rulesDir) + .filter((fileName) => fileName.endsWith(".json")) + .map((fileName) => fileName.replace(".json", "")); + + describe.each(validRules)("%s", (ruleName) => { + const rulesFile = resolve(rulesDir, `${ruleName}.json`); + const testCasesFile = resolve(testCasesDir, `${ruleName}.json`); + + if (existsSync(testCasesFile)) { + const ruleSetObject = require(rulesFile); + const { testCases } = require(testCasesFile); + + for (const testCase of testCases) { + const { documentation, params } = testCase; + (testCase.skip ? xit : it)(documentation, () => { + const _expect = testCase.expect; + + const { endpoint, error } = _expect; + + if (endpoint) { + expect(resolveEndpoint(ruleSetObject, { endpointParams: params })).toStrictEqual({ + ...endpoint, + url: new URL(endpoint.url), + }); + } + + if (error) { + expect(() => resolveEndpoint(ruleSetObject, { endpointParams: params })).toThrowError( + new EndpointError(error) + ); + } + }); + } + } + }); +}); diff --git a/packages/util-endpoints/src/resolveEndpoint.spec.ts b/packages/util-endpoints/src/resolveEndpoint.spec.ts new file mode 100644 index 00000000000..6878b953cf7 --- /dev/null +++ b/packages/util-endpoints/src/resolveEndpoint.spec.ts @@ -0,0 +1,151 @@ +import { resolveEndpoint } from "./resolveEndpoint"; +import { EndpointError, EndpointParams, ParameterObject, RuleSetObject } from "./types"; +import { evaluateRules } from "./utils"; + +jest.mock("./utils"); + +describe(resolveEndpoint.name, () => { + const boolParamKey = "boolParamKey"; + const stringParamKey = "stringParamKey"; + const requiredParamKey = "requiredParamKey"; + const paramWithDefaultKey = "paramWithDefaultKey"; + + const mockEndpointParams: EndpointParams = { + [boolParamKey]: true, + [stringParamKey]: "stringParamValue", + [requiredParamKey]: "requiredParamValue", + [paramWithDefaultKey]: "defaultParamValue", + }; + + const mockRules = []; + const mockRuleSetParameters: Record = { + [boolParamKey]: { + type: "Boolean", + }, + [stringParamKey]: { + type: "String", + }, + [requiredParamKey]: { + type: "String", + required: true, + }, + [paramWithDefaultKey]: { + type: "String", + default: "paramWithDefaultValue", + }, + }; + + const mockRuleSetObject: RuleSetObject = { + version: "1.0", + serviceId: "serviceId", + parameters: mockRuleSetParameters, + rules: mockRules, + }; + + const mockResolvedEndpoint = { url: new URL("http://example.com") }; + + beforeEach(() => { + (evaluateRules as jest.Mock).mockReturnValue(mockResolvedEndpoint); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it("should use the default value if a parameter is not set", () => { + const { paramWithDefaultKey: ignored, ...endpointParamsWithoutDefault } = mockEndpointParams; + + const resolvedEndpoint = resolveEndpoint(mockRuleSetObject, { endpointParams: endpointParamsWithoutDefault }); + expect(resolvedEndpoint).toEqual(mockResolvedEndpoint); + + expect(evaluateRules).toHaveBeenCalledWith(mockRules, { + endpointParams: { + ...mockEndpointParams, + [paramWithDefaultKey]: mockRuleSetParameters[paramWithDefaultKey].default, + }, + referenceRecord: {}, + }); + }); + + it("should throw an error if a required parameter is missing", () => { + const { requiredParamKey: ignored, ...endpointParamsWithoutRequired } = mockEndpointParams; + expect(() => resolveEndpoint(mockRuleSetObject, { endpointParams: endpointParamsWithoutRequired })).toThrow( + new EndpointError(`Missing required parameter: '${requiredParamKey}'`) + ); + expect(evaluateRules).not.toHaveBeenCalled(); + }); + + it("should not throw an error if a default value is available for required parameter", () => { + const { requiredParamKey: ignored, ...endpointParamsWithoutRequired } = mockEndpointParams; + const requiredParamDefaultValue = "requiredParamDefaultValue"; + + const resolvedEndpoint = resolveEndpoint( + { + ...mockRuleSetObject, + parameters: { + ...mockRuleSetParameters, + [requiredParamKey]: { + ...mockRuleSetParameters[requiredParamKey], + default: requiredParamDefaultValue, + }, + }, + }, + { endpointParams: endpointParamsWithoutRequired } + ); + expect(resolvedEndpoint).toEqual(mockResolvedEndpoint); + + expect(evaluateRules).toHaveBeenCalledWith(mockRules, { + endpointParams: { + ...mockEndpointParams, + [requiredParamKey]: requiredParamDefaultValue, + }, + referenceRecord: {}, + }); + }); + + it("should call evaluateRules", () => { + const resolvedEndpoint = resolveEndpoint(mockRuleSetObject, { endpointParams: mockEndpointParams }); + expect(resolvedEndpoint).toEqual(mockResolvedEndpoint); + expect(evaluateRules).toHaveBeenCalledWith(mockRules, { + endpointParams: mockEndpointParams, + referenceRecord: {}, + }); + }); + + it("should debug proper infos", () => { + const { paramWithDefaultKey: ignored, ...endpointParamsWithoutDefault } = mockEndpointParams; + const mockLogger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + + const resolvedEndpoint = resolveEndpoint(mockRuleSetObject, { + endpointParams: endpointParamsWithoutDefault, + logger: mockLogger, + }); + expect(resolvedEndpoint).toEqual(mockResolvedEndpoint); + + expect(evaluateRules).toHaveBeenCalledWith(mockRules, { + endpointParams: { + ...mockEndpointParams, + [paramWithDefaultKey]: mockRuleSetParameters[paramWithDefaultKey].default, + }, + logger: mockLogger, + referenceRecord: {}, + }); + + expect(mockLogger.debug).nthCalledWith( + 1, + "endpoints " + + "Initial EndpointParams: " + + "{\n" + + ' "boolParamKey": true,\n' + + ' "stringParamKey": "stringParamValue",\n' + + ' "requiredParamKey": "requiredParamValue"\n' + + "}" + ); + expect(mockLogger.debug).nthCalledWith(2, `endpoints Resolved endpoint: {\n "url": "http://example.com/"\n}`); + }); +}); diff --git a/packages/util-endpoints/src/resolveEndpoint.ts b/packages/util-endpoints/src/resolveEndpoint.ts new file mode 100644 index 00000000000..a3837a7e2e3 --- /dev/null +++ b/packages/util-endpoints/src/resolveEndpoint.ts @@ -0,0 +1,54 @@ +import { EndpointV2 } from "@smithy/types"; + +import { debugId, toDebugString } from "./debug"; +import { EndpointError, EndpointResolverOptions, RuleSetObject } from "./types"; +import { evaluateRules } from "./utils"; + +/** + * Resolves an endpoint URL by processing the endpoints ruleset and options. + */ +export const resolveEndpoint = (ruleSetObject: RuleSetObject, options: EndpointResolverOptions): EndpointV2 => { + const { endpointParams, logger } = options; + const { parameters, rules } = ruleSetObject; + + options.logger?.debug?.(`${debugId} Initial EndpointParams: ${toDebugString(endpointParams)}`); + + // @ts-ignore Type 'undefined' is not assignable to type 'string | boolean' (2322) + const paramsWithDefault: [string, string | boolean][] = Object.entries(parameters) + .filter(([, v]) => v.default != null) + .map(([k, v]) => [k, v.default]); + + if (paramsWithDefault.length > 0) { + for (const [paramKey, paramDefaultValue] of paramsWithDefault) { + endpointParams[paramKey] = endpointParams[paramKey] ?? paramDefaultValue; + } + } + + const requiredParams = Object.entries(parameters) + .filter(([, v]) => v.required) + .map(([k]) => k); + + for (const requiredParam of requiredParams) { + if (endpointParams[requiredParam] == null) { + throw new EndpointError(`Missing required parameter: '${requiredParam}'`); + } + } + + const endpoint = evaluateRules(rules, { endpointParams, logger, referenceRecord: {} }); + + if (options.endpointParams?.Endpoint) { + // take protocol and port from custom Endpoint if present. + try { + const givenEndpoint = new URL(options.endpointParams.Endpoint as string); + const { protocol, port } = givenEndpoint; + endpoint.url.protocol = protocol; + endpoint.url.port = port; + } catch (e) { + // ignored + } + } + + options.logger?.debug?.(`${debugId} Resolved endpoint: ${toDebugString(endpoint)}`); + + return endpoint; +}; diff --git a/packages/util-endpoints/src/types/EndpointError.ts b/packages/util-endpoints/src/types/EndpointError.ts new file mode 100644 index 00000000000..00319a4bf3d --- /dev/null +++ b/packages/util-endpoints/src/types/EndpointError.ts @@ -0,0 +1,6 @@ +export class EndpointError extends Error { + constructor(message: string) { + super(message); + this.name = "EndpointError"; + } +} diff --git a/packages/util-endpoints/src/types/EndpointFunctions.ts b/packages/util-endpoints/src/types/EndpointFunctions.ts new file mode 100644 index 00000000000..5887578f25e --- /dev/null +++ b/packages/util-endpoints/src/types/EndpointFunctions.ts @@ -0,0 +1,3 @@ +import { FunctionReturn } from "./shared"; + +export type EndpointFunctions = Record FunctionReturn>; diff --git a/packages/util-endpoints/src/types/EndpointRuleObject.ts b/packages/util-endpoints/src/types/EndpointRuleObject.ts new file mode 100644 index 00000000000..108480175fe --- /dev/null +++ b/packages/util-endpoints/src/types/EndpointRuleObject.ts @@ -0,0 +1,18 @@ +import { EndpointObjectProperty } from "@smithy/types"; + +import { ConditionObject, Expression } from "./shared"; + +export type EndpointObjectProperties = Record; +export type EndpointObjectHeaders = Record; +export type EndpointObject = { + url: Expression; + properties?: EndpointObjectProperties; + headers?: EndpointObjectHeaders; +}; + +export type EndpointRuleObject = { + type: "endpoint"; + conditions?: ConditionObject[]; + endpoint: EndpointObject; + documentation?: string; +}; diff --git a/packages/util-endpoints/src/types/ErrorRuleObject.ts b/packages/util-endpoints/src/types/ErrorRuleObject.ts new file mode 100644 index 00000000000..e03239a11b9 --- /dev/null +++ b/packages/util-endpoints/src/types/ErrorRuleObject.ts @@ -0,0 +1,8 @@ +import { ConditionObject, Expression } from "./shared"; + +export type ErrorRuleObject = { + type: "error"; + conditions?: ConditionObject[]; + error: Expression; + documentation?: string; +}; diff --git a/packages/util-endpoints/src/types/RuleSetObject.ts b/packages/util-endpoints/src/types/RuleSetObject.ts new file mode 100644 index 00000000000..013e466f89a --- /dev/null +++ b/packages/util-endpoints/src/types/RuleSetObject.ts @@ -0,0 +1,22 @@ +import { RuleSetRules } from "./TreeRuleObject"; + +export type DeprecatedObject = { + message?: string; + since?: string; +}; + +export type ParameterObject = { + type: "String" | "Boolean"; + default?: string | boolean; + required?: boolean; + documentation?: string; + builtIn?: string; + deprecated?: DeprecatedObject; +}; + +export type RuleSetObject = { + version: string; + serviceId?: string; + parameters: Record; + rules: RuleSetRules; +}; diff --git a/packages/util-endpoints/src/types/TreeRuleObject.ts b/packages/util-endpoints/src/types/TreeRuleObject.ts new file mode 100644 index 00000000000..b1b7476561a --- /dev/null +++ b/packages/util-endpoints/src/types/TreeRuleObject.ts @@ -0,0 +1,12 @@ +import { EndpointRuleObject } from "./EndpointRuleObject"; +import { ErrorRuleObject } from "./ErrorRuleObject"; +import { ConditionObject } from "./shared"; + +export type RuleSetRules = Array; + +export type TreeRuleObject = { + type: "tree"; + conditions?: ConditionObject[]; + rules: RuleSetRules; + documentation?: string; +}; diff --git a/packages/util-endpoints/src/types/index.ts b/packages/util-endpoints/src/types/index.ts new file mode 100644 index 00000000000..a49f9840ac4 --- /dev/null +++ b/packages/util-endpoints/src/types/index.ts @@ -0,0 +1,7 @@ +export * from "./EndpointError"; +export * from "./EndpointFunctions"; +export * from "./EndpointRuleObject"; +export * from "./ErrorRuleObject"; +export * from "./RuleSetObject"; +export * from "./TreeRuleObject"; +export * from "./shared"; diff --git a/packages/util-endpoints/src/types/shared.ts b/packages/util-endpoints/src/types/shared.ts new file mode 100644 index 00000000000..7d1d07e93d6 --- /dev/null +++ b/packages/util-endpoints/src/types/shared.ts @@ -0,0 +1,29 @@ +import { EndpointARN, EndpointPartition, Logger } from "@smithy/types"; + +export type ReferenceObject = { ref: string }; + +export type FunctionObject = { fn: string; argv: FunctionArgv }; +export type FunctionArgv = Array; +export type FunctionReturn = + | string + | boolean + | number + | EndpointARN + | EndpointPartition + | { [key: string]: FunctionReturn } + | null; + +export type ConditionObject = FunctionObject & { assign?: string }; + +export type Expression = string | ReferenceObject | FunctionObject; + +export type EndpointParams = Record; +export type EndpointResolverOptions = { + endpointParams: EndpointParams; + logger?: Logger; +}; + +export type ReferenceRecord = Record; +export type EvaluateOptions = EndpointResolverOptions & { + referenceRecord: ReferenceRecord; +}; diff --git a/packages/util-endpoints/src/utils/callFunction.spec.ts b/packages/util-endpoints/src/utils/callFunction.spec.ts new file mode 100644 index 00000000000..2bd03a8e1b4 --- /dev/null +++ b/packages/util-endpoints/src/utils/callFunction.spec.ts @@ -0,0 +1,82 @@ +import { callFunction } from "./callFunction"; +import { customEndpointFunctions } from "./customEndpointFunctions"; +import { endpointFunctions } from "./endpointFunctions"; +import { evaluateExpression } from "./evaluateExpression"; + +jest.mock("./evaluateExpression"); + +describe(callFunction.name, () => { + const mockOptions = { + endpointParams: {}, + referenceRecord: {}, + }; + const mockReturn = "mockReturn"; + const mockArgReturn = "mockArgReturn"; + + beforeEach(() => { + (evaluateExpression as jest.Mock).mockReturnValue(mockArgReturn); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it.each([ + "booleanEquals", + "getAttr", + "isSet", + "isValidHostLabel", + "not", + "parseURL", + "stringEquals", + "subsgtring", + "urlEncode", + ])("calls built-in endpoint function %s", (builtIn) => { + endpointFunctions[builtIn] = jest.fn().mockReturnValue(mockReturn); + const mockArg = "mockArg"; + const mockFn = { fn: builtIn, argv: [mockArg] }; + + const result = callFunction(mockFn, mockOptions); + expect(result).toBe(mockReturn); + expect(endpointFunctions[builtIn]).toHaveBeenCalledWith(mockArgReturn); + }); + + it.each([ + ["boolean", true], + ["boolean", false], + ["number", 1], + ["number", 0], + ])("skips evaluateExpression for %s arg: %s", (argType, mockNotExpressionArg) => { + const mockFn = { fn: "booleanEquals", argv: [mockNotExpressionArg] }; + const result = callFunction(mockFn, mockOptions); + expect(result).toBe(mockReturn); + expect(evaluateExpression).not.toHaveBeenCalled(); + expect(endpointFunctions.booleanEquals).toHaveBeenCalledWith(mockNotExpressionArg); + }); + + it.each(["string", { ref: "ref" }, { fn: "fn", argv: [] }])( + "calls evaluateExpression for expression arg: %s", + (arg) => { + const mockFn = { fn: "booleanEquals", argv: [arg] }; + + const result = callFunction(mockFn, mockOptions); + expect(result).toBe(mockReturn); + expect(evaluateExpression).toHaveBeenCalledWith(arg, "arg", mockOptions); + expect(endpointFunctions.booleanEquals).toHaveBeenCalledWith(mockArgReturn); + } + ); + + it("calls custom endpoint functions", () => { + const mockCustomFunction = jest.fn().mockReturnValue(mockReturn); + customEndpointFunctions["ns"] = { + mockCustomFunction, + }; + const mockArg = "mockArg"; + const mockFn = { fn: `ns.mockCustomFunction`, argv: [mockArg] }; + + const result = callFunction(mockFn, mockOptions); + expect(result).toBe(mockReturn); + expect(evaluateExpression).toHaveBeenCalledWith(mockArg, "arg", mockOptions); + expect(mockCustomFunction).toHaveBeenCalledWith(mockArgReturn); + }); +}); diff --git a/packages/util-endpoints/src/utils/callFunction.ts b/packages/util-endpoints/src/utils/callFunction.ts new file mode 100644 index 00000000000..6e4faf492b2 --- /dev/null +++ b/packages/util-endpoints/src/utils/callFunction.ts @@ -0,0 +1,17 @@ +import { EvaluateOptions, Expression, FunctionObject, FunctionReturn } from "../types"; +import { customEndpointFunctions } from "./customEndpointFunctions"; +import { endpointFunctions } from "./endpointFunctions"; +import { evaluateExpression } from "./evaluateExpression"; + +export const callFunction = ({ fn, argv }: FunctionObject, options: EvaluateOptions): FunctionReturn => { + const evaluatedArgs = argv.map((arg) => + ["boolean", "number"].includes(typeof arg) ? arg : evaluateExpression(arg as Expression, "arg", options) + ); + const fnSegments = fn.split("."); + if (fnSegments[0] in customEndpointFunctions && fnSegments[1] != null) { + // @ts-ignore Element implicitly has an 'any' type + return customEndpointFunctions[fnSegments[0]][fnSegments[1]](...evaluatedArgs); + } + // @ts-ignore Element implicitly has an 'any' type + return endpointFunctions[fn](...evaluatedArgs); +}; diff --git a/packages/util-endpoints/src/utils/customEndpointFunctions.ts b/packages/util-endpoints/src/utils/customEndpointFunctions.ts new file mode 100644 index 00000000000..b7c1ca4be79 --- /dev/null +++ b/packages/util-endpoints/src/utils/customEndpointFunctions.ts @@ -0,0 +1,3 @@ +import { EndpointFunctions } from "../types/EndpointFunctions"; + +export const customEndpointFunctions: { [key: string]: EndpointFunctions } = {}; diff --git a/packages/util-endpoints/src/utils/endpointFunctions.ts b/packages/util-endpoints/src/utils/endpointFunctions.ts new file mode 100644 index 00000000000..66c1bdb2fce --- /dev/null +++ b/packages/util-endpoints/src/utils/endpointFunctions.ts @@ -0,0 +1,23 @@ +import { + booleanEquals, + getAttr, + isSet, + isValidHostLabel, + not, + parseURL, + stringEquals, + substring, + uriEncode, +} from "../lib"; + +export const endpointFunctions = { + booleanEquals, + getAttr, + isSet, + isValidHostLabel, + not, + parseURL, + stringEquals, + substring, + uriEncode, +}; diff --git a/packages/util-endpoints/src/utils/evaluateCondition.spec.ts b/packages/util-endpoints/src/utils/evaluateCondition.spec.ts new file mode 100644 index 00000000000..65348c2bf75 --- /dev/null +++ b/packages/util-endpoints/src/utils/evaluateCondition.spec.ts @@ -0,0 +1,49 @@ +import { EndpointError, EvaluateOptions } from "../types"; +import { callFunction } from "./callFunction"; +import { evaluateCondition } from "./evaluateCondition"; + +jest.mock("./callFunction"); + +describe(evaluateCondition.name, () => { + const mockOptions: EvaluateOptions = { + endpointParams: {}, + referenceRecord: {}, + }; + const mockAssign = "mockAssign"; + const mockFnArgs = { fn: "fn", argv: ["arg"] }; + + it("throws error if assign is already defined in Reference Record", () => { + const mockOptionsWithAssign = { + ...mockOptions, + referenceRecord: { + [mockAssign]: true, + }, + }; + expect(() => evaluateCondition({ assign: mockAssign, ...mockFnArgs }, mockOptionsWithAssign)).toThrow( + new EndpointError(`'${mockAssign}' is already defined in Reference Record.`) + ); + expect(callFunction).not.toHaveBeenCalled(); + }); + + describe("evaluates function", () => { + describe.each([ + [true, [true, 1, -1, "true", "false", ""]], + [false, [false, 0, -0, null, undefined, NaN]], + ])("returns %s for", (result, testCases) => { + it.each(testCases)(`value: '%s'`, (mockReturn) => { + (callFunction as jest.Mock).mockReturnValue(mockReturn); + const { result, toAssign } = evaluateCondition(mockFnArgs, mockOptions); + expect(result).toBe(result); + expect(toAssign).toBeUndefined(); + }); + }); + }); + + it("returns assigned value if defined", () => { + const mockAssignedValue = "mockAssignedValue"; + (callFunction as jest.Mock).mockReturnValue(mockAssignedValue); + const { result, toAssign } = evaluateCondition({ assign: mockAssign, ...mockFnArgs }, mockOptions); + expect(result).toBe(true); + expect(toAssign).toEqual({ name: mockAssign, value: mockAssignedValue }); + }); +}); diff --git a/packages/util-endpoints/src/utils/evaluateCondition.ts b/packages/util-endpoints/src/utils/evaluateCondition.ts new file mode 100644 index 00000000000..7f26abf0cdf --- /dev/null +++ b/packages/util-endpoints/src/utils/evaluateCondition.ts @@ -0,0 +1,17 @@ +import { debugId, toDebugString } from "../debug"; +import { ConditionObject, EndpointError, EvaluateOptions } from "../types"; +import { callFunction } from "./callFunction"; + +export const evaluateCondition = ({ assign, ...fnArgs }: ConditionObject, options: EvaluateOptions) => { + if (assign && assign in options.referenceRecord) { + throw new EndpointError(`'${assign}' is already defined in Reference Record.`); + } + const value = callFunction(fnArgs, options); + + options.logger?.debug?.(debugId, `evaluateCondition: ${toDebugString(fnArgs)} = ${toDebugString(value)}`); + + return { + result: value === "" ? true : !!value, + ...(assign != null && { toAssign: { name: assign, value } }), + }; +}; diff --git a/packages/util-endpoints/src/utils/evaluateConditions.spec.ts b/packages/util-endpoints/src/utils/evaluateConditions.spec.ts new file mode 100644 index 00000000000..ef4041ab741 --- /dev/null +++ b/packages/util-endpoints/src/utils/evaluateConditions.spec.ts @@ -0,0 +1,64 @@ +import { ConditionObject, EvaluateOptions, FunctionReturn } from "../types"; +import { evaluateCondition } from "./evaluateCondition"; +import { evaluateConditions } from "./evaluateConditions"; + +jest.mock("./evaluateCondition"); + +describe(evaluateConditions.name, () => { + const mockOptions: EvaluateOptions = { + endpointParams: {}, + referenceRecord: {}, + }; + const mockCn1: ConditionObject = { fn: "fn1", argv: ["arg1"], assign: "assign1" }; + const mockCn2: ConditionObject = { fn: "fn2", argv: ["arg2"], assign: "assign2" }; + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("returns false as soon as one condition is false", () => { + it("first condition is false", () => { + (evaluateCondition as jest.Mock).mockReturnValueOnce({ result: false }); + const { result, referenceRecord } = evaluateConditions([mockCn1, mockCn2], mockOptions); + expect(result).toBe(false); + expect(referenceRecord).toBeUndefined(); + expect(evaluateCondition).toHaveBeenCalledWith(mockCn1, mockOptions); + }); + + it("second condition is false", () => { + (evaluateCondition as jest.Mock).mockReturnValueOnce({ result: true }); + (evaluateCondition as jest.Mock).mockReturnValueOnce({ result: false }); + const { result, referenceRecord } = evaluateConditions([mockCn1, mockCn2], mockOptions); + expect(result).toBe(false); + expect(referenceRecord).toBeUndefined(); + expect(evaluateCondition).toHaveBeenNthCalledWith(1, mockCn1, mockOptions); + expect(evaluateCondition).toHaveBeenNthCalledWith(2, mockCn2, mockOptions); + }); + }); + + it("returns true if all conditions are true with referenceRecord", () => { + const value1 = "value1"; + const value2 = "value2"; + + (evaluateCondition as jest.Mock).mockReturnValueOnce({ + result: true, + toAssign: { name: mockCn1.assign, value: value1 }, + }); + (evaluateCondition as jest.Mock).mockReturnValueOnce({ + result: true, + toAssign: { name: mockCn2.assign, value: value2 }, + }); + + const { result, referenceRecord } = evaluateConditions([mockCn1, mockCn2], mockOptions); + expect(result).toBe(true); + expect(referenceRecord).toEqual({ + [mockCn1.assign!]: value1, + [mockCn2.assign!]: value2, + }); + expect(evaluateCondition).toHaveBeenNthCalledWith(1, mockCn1, mockOptions); + expect(evaluateCondition).toHaveBeenNthCalledWith(2, mockCn2, { + ...mockOptions, + referenceRecord: { [mockCn1.assign!]: value1 }, + }); + }); +}); diff --git a/packages/util-endpoints/src/utils/evaluateConditions.ts b/packages/util-endpoints/src/utils/evaluateConditions.ts new file mode 100644 index 00000000000..24e34cb5e92 --- /dev/null +++ b/packages/util-endpoints/src/utils/evaluateConditions.ts @@ -0,0 +1,28 @@ +import { debugId, toDebugString } from "../debug"; +import { ConditionObject, EvaluateOptions, FunctionReturn } from "../types"; +import { evaluateCondition } from "./evaluateCondition"; + +export const evaluateConditions = (conditions: ConditionObject[] = [], options: EvaluateOptions) => { + const conditionsReferenceRecord: Record = {}; + + for (const condition of conditions) { + const { result, toAssign } = evaluateCondition(condition, { + ...options, + referenceRecord: { + ...options.referenceRecord, + ...conditionsReferenceRecord, + }, + }); + + if (!result) { + return { result }; + } + + if (toAssign) { + conditionsReferenceRecord[toAssign.name] = toAssign.value; + options.logger?.debug?.(debugId, `assign: ${toAssign.name} := ${toDebugString(toAssign.value)}`); + } + } + + return { result: true, referenceRecord: conditionsReferenceRecord }; +}; diff --git a/packages/util-endpoints/src/utils/evaluateEndpointRule.spec.ts b/packages/util-endpoints/src/utils/evaluateEndpointRule.spec.ts new file mode 100644 index 00000000000..735d3e00aae --- /dev/null +++ b/packages/util-endpoints/src/utils/evaluateEndpointRule.spec.ts @@ -0,0 +1,101 @@ +import { ConditionObject, EndpointRuleObject } from "../types"; +import { evaluateConditions } from "./evaluateConditions"; +import { evaluateEndpointRule } from "./evaluateEndpointRule"; +import { getEndpointHeaders } from "./getEndpointHeaders"; +import { getEndpointProperties } from "./getEndpointProperties"; +import { getEndpointUrl } from "./getEndpointUrl"; + +jest.mock("./evaluateConditions"); +jest.mock("./getEndpointUrl"); +jest.mock("./getEndpointHeaders"); +jest.mock("./getEndpointProperties"); + +describe(evaluateEndpointRule.name, () => { + const mockOptions = { + endpointParams: {}, + referenceRecord: {}, + }; + const mockConditions: ConditionObject[] = [ + { fn: "fn1", argv: ["arg1"] }, + { fn: "fn2", argv: ["arg2"] }, + ]; + const mockEndpoint = { url: "http://example.com" }; + const mockEndpointRule: EndpointRuleObject = { + type: "endpoint", + conditions: mockConditions, + endpoint: mockEndpoint, + }; + + it("returns undefined if conditions are false", () => { + (evaluateConditions as jest.Mock).mockReturnValue({ result: false }); + const result = evaluateEndpointRule(mockEndpointRule, mockOptions); + expect(result).toBeUndefined(); + expect(evaluateConditions).toHaveBeenCalledWith(mockConditions, mockOptions); + expect(getEndpointUrl).not.toHaveBeenCalled(); + expect(getEndpointHeaders).not.toHaveBeenCalled(); + expect(getEndpointProperties).not.toHaveBeenCalled(); + }); + + describe("returns endpoint if conditions are true", () => { + const mockReferenceRecord = { key: "value" }; + const mockEndpointUrl = new URL(mockEndpoint.url); + const mockUpdatedOptions = { + ...mockOptions, + referenceRecord: { ...mockOptions.referenceRecord, ...mockReferenceRecord }, + }; + + beforeEach(() => { + (evaluateConditions as jest.Mock).mockReturnValue({ + result: true, + referenceRecord: mockReferenceRecord, + }); + (getEndpointUrl as jest.Mock).mockReturnValue(mockEndpointUrl); + }); + + afterEach(() => { + expect(evaluateConditions).toHaveBeenCalledWith(mockConditions, mockOptions); + expect(getEndpointUrl).toHaveBeenCalledWith(mockEndpoint.url, mockUpdatedOptions); + jest.clearAllMocks(); + }); + + it("without headers and properties", () => { + const result = evaluateEndpointRule(mockEndpointRule, mockOptions); + expect(result).toEqual({ + url: mockEndpointUrl, + }); + expect(getEndpointHeaders).not.toHaveBeenCalled(); + expect(getEndpointProperties).not.toHaveBeenCalled(); + }); + + it("with headers and properties", () => { + const mockInputHeaders = { headerKey: ["headerInputValue"] }; + const mockInputProperties = { propertyKey: "propertyInputValue" }; + + const mockOutputHeaders = { headerKey: ["headerOutputValue"] }; + const mockOutputProperties = { propertyKey: "propertyOutputValue" }; + + (getEndpointHeaders as jest.Mock).mockReturnValue(mockOutputHeaders); + (getEndpointProperties as jest.Mock).mockReturnValue(mockOutputProperties); + + const result = evaluateEndpointRule( + { + ...mockEndpointRule, + endpoint: { + ...mockEndpoint, + headers: mockInputHeaders, + properties: mockInputProperties, + }, + }, + mockOptions + ); + + expect(result).toEqual({ + url: mockEndpointUrl, + headers: mockOutputHeaders, + properties: mockOutputProperties, + }); + expect(getEndpointHeaders).toHaveBeenCalledWith(mockInputHeaders, mockUpdatedOptions); + expect(getEndpointProperties).toHaveBeenCalledWith(mockInputProperties, mockUpdatedOptions); + }); + }); +}); diff --git a/packages/util-endpoints/src/utils/evaluateEndpointRule.ts b/packages/util-endpoints/src/utils/evaluateEndpointRule.ts new file mode 100644 index 00000000000..e5e3eaa48de --- /dev/null +++ b/packages/util-endpoints/src/utils/evaluateEndpointRule.ts @@ -0,0 +1,39 @@ +import { EndpointV2 } from "@smithy/types"; + +import { debugId, toDebugString } from "../debug"; +import { EndpointRuleObject, EvaluateOptions } from "../types"; +import { evaluateConditions } from "./evaluateConditions"; +import { getEndpointHeaders } from "./getEndpointHeaders"; +import { getEndpointProperties } from "./getEndpointProperties"; +import { getEndpointUrl } from "./getEndpointUrl"; + +export const evaluateEndpointRule = ( + endpointRule: EndpointRuleObject, + options: EvaluateOptions +): EndpointV2 | undefined => { + const { conditions, endpoint } = endpointRule; + + const { result, referenceRecord } = evaluateConditions(conditions, options); + if (!result) { + return; + } + + const endpointRuleOptions = { + ...options, + referenceRecord: { ...options.referenceRecord, ...referenceRecord }, + }; + + const { url, properties, headers } = endpoint; + + options.logger?.debug?.(debugId, `Resolving endpoint from template: ${toDebugString(endpoint)}`); + + return { + ...(headers != undefined && { + headers: getEndpointHeaders(headers, endpointRuleOptions), + }), + ...(properties != undefined && { + properties: getEndpointProperties(properties, endpointRuleOptions), + }), + url: getEndpointUrl(url, endpointRuleOptions), + }; +}; diff --git a/packages/util-endpoints/src/utils/evaluateErrorRule.spec.ts b/packages/util-endpoints/src/utils/evaluateErrorRule.spec.ts new file mode 100644 index 00000000000..ca606da2962 --- /dev/null +++ b/packages/util-endpoints/src/utils/evaluateErrorRule.spec.ts @@ -0,0 +1,47 @@ +import { EndpointError, ErrorRuleObject } from "../types"; +import { evaluateConditions } from "./evaluateConditions"; +import { evaluateErrorRule } from "./evaluateErrorRule"; +import { evaluateExpression } from "./evaluateExpression"; + +jest.mock("./evaluateConditions"); +jest.mock("./evaluateExpression"); + +describe(evaluateErrorRule.name, () => { + const mockOptions = { + endpointParams: {}, + referenceRecord: {}, + }; + const mockConditions = [ + { fn: "fn1", argv: ["arg1"] }, + { fn: "fn2", argv: ["arg2"] }, + ]; + const mockError = "mockError"; + const mockErrorRule: ErrorRuleObject = { + type: "error", + conditions: mockConditions, + error: mockError, + }; + + it("returns undefined if conditions evaluate to false", () => { + (evaluateConditions as jest.Mock).mockReturnValue({ result: false }); + const result = evaluateErrorRule(mockErrorRule, mockOptions); + expect(result).toBeUndefined(); + expect(evaluateConditions).toHaveBeenCalledWith(mockConditions, mockOptions); + expect(evaluateExpression).not.toHaveBeenCalled(); + }); + + it("throws error if conditions evaluate to true", () => { + const mockErrorMsg = "mockErrorMsg"; + const mockReferenceRecord = { key: "value" }; + + (evaluateConditions as jest.Mock).mockReturnValue({ result: true, referenceRecord: mockReferenceRecord }); + (evaluateExpression as jest.Mock).mockReturnValue(mockErrorMsg); + + expect(() => evaluateErrorRule(mockErrorRule, mockOptions)).toThrowError(new EndpointError(`mockErrorMsg`)); + expect(evaluateConditions).toHaveBeenCalledWith(mockConditions, mockOptions); + expect(evaluateExpression).toHaveBeenCalledWith(mockError, "Error", { + ...mockOptions, + referenceRecord: { ...mockOptions.referenceRecord, ...mockReferenceRecord }, + }); + }); +}); diff --git a/packages/util-endpoints/src/utils/evaluateErrorRule.ts b/packages/util-endpoints/src/utils/evaluateErrorRule.ts new file mode 100644 index 00000000000..78a8a3c97f7 --- /dev/null +++ b/packages/util-endpoints/src/utils/evaluateErrorRule.ts @@ -0,0 +1,19 @@ +import { EndpointError, ErrorRuleObject, EvaluateOptions } from "../types"; +import { evaluateConditions } from "./evaluateConditions"; +import { evaluateExpression } from "./evaluateExpression"; + +export const evaluateErrorRule = (errorRule: ErrorRuleObject, options: EvaluateOptions) => { + const { conditions, error } = errorRule; + + const { result, referenceRecord } = evaluateConditions(conditions, options); + if (!result) { + return; + } + + throw new EndpointError( + evaluateExpression(error, "Error", { + ...options, + referenceRecord: { ...options.referenceRecord, ...referenceRecord }, + }) as string + ); +}; diff --git a/packages/util-endpoints/src/utils/evaluateExpression.spec.ts b/packages/util-endpoints/src/utils/evaluateExpression.spec.ts new file mode 100644 index 00000000000..28dfdf64b5d --- /dev/null +++ b/packages/util-endpoints/src/utils/evaluateExpression.spec.ts @@ -0,0 +1,63 @@ +import { EndpointError } from "../types"; +import { callFunction } from "./callFunction"; +import { evaluateExpression } from "./evaluateExpression"; +import { evaluateTemplate } from "./evaluateTemplate"; +import { getReferenceValue } from "./getReferenceValue"; + +jest.mock("./callFunction"); +jest.mock("./getReferenceValue"); +jest.mock("./evaluateTemplate"); + +describe(evaluateExpression.name, () => { + const mockOptions = { + endpointParams: {}, + referenceRecord: {}, + }; + const mockKeyName = "mockKeyName"; + const mockResult = "mockResult"; + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("calls evaluateTemplate if input is string", () => { + const mockInput = "mockInput"; + (evaluateTemplate as jest.Mock).mockReturnValue(mockResult); + const result = evaluateExpression(mockInput, mockKeyName, mockOptions); + expect(result).toBe(mockResult); + expect(evaluateTemplate).toHaveBeenCalledWith(mockInput, mockOptions); + expect(callFunction).not.toHaveBeenCalled(); + expect(getReferenceValue).not.toHaveBeenCalled(); + }); + + it("calls callFunction if input constains 'fn' key", () => { + const mockInput = { fn: "fn", argv: ["arg1"] }; + (callFunction as jest.Mock).mockReturnValue(mockResult); + const result = evaluateExpression(mockInput, mockKeyName, mockOptions); + expect(result).toBe(mockResult); + expect(evaluateTemplate).not.toHaveBeenCalled(); + expect(callFunction).toHaveBeenCalledWith(mockInput, mockOptions); + expect(getReferenceValue).not.toHaveBeenCalled(); + }); + + it("calls getReferenceValue if input constains 'ref' key", () => { + const mockInput = { ref: "ref" }; + (getReferenceValue as jest.Mock).mockReturnValue(mockResult); + const result = evaluateExpression(mockInput, mockKeyName, mockOptions); + expect(result).toBe(mockResult); + expect(evaluateTemplate).not.toHaveBeenCalled(); + expect(callFunction).not.toHaveBeenCalled(); + expect(getReferenceValue).toHaveBeenCalledWith(mockInput, mockOptions); + }); + + it("throws error if input is neither string, function or reference", () => { + const mockInput = {}; + // @ts-ignore: Argument is not assignable + expect(() => evaluateExpression(mockInput, mockKeyName, mockOptions)).toThrowError( + new EndpointError(`'${mockKeyName}': ${String(mockInput)} is not a string, function or reference.`) + ); + expect(evaluateTemplate).not.toHaveBeenCalled(); + expect(callFunction).not.toHaveBeenCalled(); + expect(getReferenceValue).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/util-endpoints/src/utils/evaluateExpression.ts b/packages/util-endpoints/src/utils/evaluateExpression.ts new file mode 100644 index 00000000000..470b1267604 --- /dev/null +++ b/packages/util-endpoints/src/utils/evaluateExpression.ts @@ -0,0 +1,15 @@ +import { EndpointError, EvaluateOptions, Expression, FunctionObject, ReferenceObject } from "../types"; +import { callFunction } from "./callFunction"; +import { evaluateTemplate } from "./evaluateTemplate"; +import { getReferenceValue } from "./getReferenceValue"; + +export const evaluateExpression = (obj: Expression, keyName: string, options: EvaluateOptions) => { + if (typeof obj === "string") { + return evaluateTemplate(obj, options); + } else if ((obj as FunctionObject)["fn"]) { + return callFunction(obj as FunctionObject, options); + } else if ((obj as ReferenceObject)["ref"]) { + return getReferenceValue(obj as ReferenceObject, options); + } + throw new EndpointError(`'${keyName}': ${String(obj)} is not a string, function or reference.`); +}; diff --git a/packages/util-endpoints/src/utils/evaluateRules.spec.ts b/packages/util-endpoints/src/utils/evaluateRules.spec.ts new file mode 100644 index 00000000000..1b79e6e8524 --- /dev/null +++ b/packages/util-endpoints/src/utils/evaluateRules.spec.ts @@ -0,0 +1,107 @@ +import { EndpointError, EndpointRuleObject, ErrorRuleObject, TreeRuleObject } from "../types"; +import { evaluateEndpointRule } from "./evaluateEndpointRule"; +import { evaluateErrorRule } from "./evaluateErrorRule"; +import { evaluateRules } from "./evaluateRules"; +import { evaluateTreeRule } from "./evaluateTreeRule"; + +jest.mock("./evaluateEndpointRule"); +jest.mock("./evaluateErrorRule"); +jest.mock("./evaluateTreeRule"); + +describe(evaluateRules.name, () => { + const mockOptions = { + endpointParams: {}, + referenceRecord: {}, + }; + + const mockConditions = [ + { fn: "fn1", argv: ["arg1"] }, + { fn: "fn2", argv: ["arg2"] }, + ]; + + const mockEndpoint = { url: "http://example.com" }; + const mockEndpointRule: EndpointRuleObject = { + type: "endpoint", + conditions: mockConditions, + endpoint: mockEndpoint, + }; + + const mockError = "mockError"; + const mockErrorRule: ErrorRuleObject = { + type: "error", + conditions: mockConditions, + error: mockError, + }; + + const mockTreeRule: TreeRuleObject = { + type: "tree", + conditions: mockConditions, + rules: [], + }; + + const mockEndpointResult = { url: new URL(mockEndpoint.url) }; + const mockRules = [mockEndpointRule, mockErrorRule, mockTreeRule]; + + beforeEach(() => { + (evaluateEndpointRule as jest.Mock).mockReturnValue(undefined); + (evaluateErrorRule as jest.Mock).mockReturnValue(undefined); + (evaluateTreeRule as jest.Mock).mockReturnValue(undefined); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe("returns endpoint if defined", () => { + it("from EndPoint Rule", () => { + (evaluateEndpointRule as jest.Mock).mockReturnValue(mockEndpointResult); + const result = evaluateRules(mockRules, mockOptions); + expect(result).toEqual(mockEndpointResult); + expect(evaluateEndpointRule).toHaveBeenCalledWith(mockEndpointRule, mockOptions); + expect(evaluateErrorRule).not.toHaveBeenCalled(); + expect(evaluateTreeRule).not.toHaveBeenCalled(); + }); + + it("from Tree Rule", () => { + (evaluateTreeRule as jest.Mock).mockReturnValue(mockEndpointResult); + const result = evaluateRules(mockRules, mockOptions); + expect(result).toEqual(mockEndpointResult); + expect(evaluateEndpointRule).toHaveBeenCalledWith(mockEndpointRule, mockOptions); + expect(evaluateErrorRule).toHaveBeenCalledWith(mockErrorRule, mockOptions); + expect(evaluateTreeRule).toHaveBeenCalledWith(mockTreeRule, mockOptions); + }); + }); + + it("re-throws error from Error Rule, if it occurs before endpoint evaluation", () => { + const mockError = new Error("mockError"); + (evaluateErrorRule as jest.Mock).mockImplementation(() => { + throw mockError; + }); + expect(() => evaluateRules(mockRules, mockOptions)).toThrow(mockError); + expect(evaluateEndpointRule).toHaveBeenCalledWith(mockEndpointRule, mockOptions); + expect(evaluateErrorRule).toHaveBeenCalledWith(mockErrorRule, mockOptions); + expect(evaluateTreeRule).not.toHaveBeenCalled(); + }); + + it("throws error for unknown endpoint rule", () => { + const mockUnknownRule = { + type: "unknown", + conditions: mockConditions, + endpoint: mockEndpoint, + }; + // @ts-ignore: Argument not assignable + expect(() => evaluateRules([mockUnknownRule], mockOptions)).toThrow( + new EndpointError(`Unknown endpoint rule: ${mockUnknownRule}`) + ); + expect(evaluateEndpointRule).not.toHaveBeenCalled(); + expect(evaluateErrorRule).not.toHaveBeenCalled(); + expect(evaluateTreeRule).not.toHaveBeenCalled(); + }); + + it("throws error if rules evaluation fails", () => { + expect(() => evaluateRules(mockRules, mockOptions)).toThrow(new EndpointError(`Rules evaluation failed`)); + expect(evaluateEndpointRule).toHaveBeenCalledWith(mockEndpointRule, mockOptions); + expect(evaluateErrorRule).toHaveBeenCalledWith(mockErrorRule, mockOptions); + expect(evaluateTreeRule).toHaveBeenCalledWith(mockTreeRule, mockOptions); + }); +}); diff --git a/packages/util-endpoints/src/utils/evaluateRules.ts b/packages/util-endpoints/src/utils/evaluateRules.ts new file mode 100644 index 00000000000..357ee2d6703 --- /dev/null +++ b/packages/util-endpoints/src/utils/evaluateRules.ts @@ -0,0 +1,27 @@ +import { EndpointV2 } from "@smithy/types"; + +import { EndpointError, EvaluateOptions, RuleSetRules } from "../types"; +import { evaluateEndpointRule } from "./evaluateEndpointRule"; +import { evaluateErrorRule } from "./evaluateErrorRule"; +import { evaluateTreeRule } from "./evaluateTreeRule"; + +export const evaluateRules = (rules: RuleSetRules, options: EvaluateOptions): EndpointV2 => { + for (const rule of rules) { + if (rule.type === "endpoint") { + const endpointOrUndefined = evaluateEndpointRule(rule, options); + if (endpointOrUndefined) { + return endpointOrUndefined; + } + } else if (rule.type === "error") { + evaluateErrorRule(rule, options); + } else if (rule.type === "tree") { + const endpointOrUndefined = evaluateTreeRule(rule, options); + if (endpointOrUndefined) { + return endpointOrUndefined; + } + } else { + throw new EndpointError(`Unknown endpoint rule: ${rule}`); + } + } + throw new EndpointError(`Rules evaluation failed`); +}; diff --git a/packages/util-endpoints/src/utils/evaluateTemplate.spec.ts b/packages/util-endpoints/src/utils/evaluateTemplate.spec.ts new file mode 100644 index 00000000000..6fbeff935e3 --- /dev/null +++ b/packages/util-endpoints/src/utils/evaluateTemplate.spec.ts @@ -0,0 +1,68 @@ +import { getAttr } from "../lib"; +import { evaluateTemplate } from "./evaluateTemplate"; + +jest.mock("../lib"); + +describe(evaluateTemplate.name, () => { + const mockOptions = { + endpointParams: {}, + referenceRecord: {}, + }; + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should not escape template without braces", () => { + const templateWithoutBraces = "foo bar baz"; + expect(evaluateTemplate(templateWithoutBraces, mockOptions)).toEqual(templateWithoutBraces); + }); + + describe("should replace `{parameterName}` with value", () => { + const parameterName = "bar"; + const template = "foo {parameterName} baz"; + + afterEach(() => { + expect(getAttr).not.toHaveBeenCalled(); + }); + + it.each(["endpointParams", "referenceRecord"])("from %s", (key: string) => { + expect(evaluateTemplate(template, { ...mockOptions, [key]: { parameterName } })).toBe(`foo ${parameterName} baz`); + }); + }); + + it("should escape values within double braces like {{value}}", () => { + const value = "bar"; + expect(evaluateTemplate("foo {{value1}} bar {{value2}} baz", { ...mockOptions, endpointParams: { value } })).toBe( + "foo {value1} bar {value2} baz" + ); + expect(getAttr).not.toHaveBeenCalled(); + }); + + it("should call getAttr for short-hand getAttr function", () => { + const ref1 = { key1: "value1" }; + const ref2 = { key2: "value2" }; + + (getAttr as jest.Mock).mockReturnValueOnce(ref1["key1"]); + (getAttr as jest.Mock).mockReturnValueOnce(ref2["key2"]); + + expect( + evaluateTemplate("foo {ref1#key1} bar {ref2#key2} baz", { ...mockOptions, referenceRecord: { ref1, ref2 } }) + ).toBe(`foo ${ref1["key1"]} bar ${ref2["key2"]} baz`); + + expect(getAttr).toHaveBeenCalledTimes(2); + expect(getAttr).toHaveBeenNthCalledWith(1, ref1, "key1"); + expect(getAttr).toHaveBeenNthCalledWith(2, ref2, "key2"); + }); + + describe("should not change template with incomplete braces", () => { + it.each([ + "incomplete opening bracket '{' in template", + "incomplete closing bracket '}' in template", + "incomplete opening escape '{{' in template", + "incomplete closing escape '}}' in template", + ])("%s", (template) => { + expect(evaluateTemplate(template, mockOptions)).toEqual(template); + }); + }); +}); diff --git a/packages/util-endpoints/src/utils/evaluateTemplate.ts b/packages/util-endpoints/src/utils/evaluateTemplate.ts new file mode 100644 index 00000000000..4e03a9602e6 --- /dev/null +++ b/packages/util-endpoints/src/utils/evaluateTemplate.ts @@ -0,0 +1,50 @@ +import { getAttr } from "../lib"; +import { EvaluateOptions } from "../types"; + +export const evaluateTemplate = (template: string, options: EvaluateOptions) => { + const evaluatedTemplateArr: string[] = []; + + const templateContext = { + ...options.endpointParams, + ...options.referenceRecord, + } as Record; + + let currentIndex = 0; + while (currentIndex < template.length) { + const openingBraceIndex = template.indexOf("{", currentIndex); + + if (openingBraceIndex === -1) { + // No more opening braces, add the rest of the template and break. + evaluatedTemplateArr.push(template.slice(currentIndex)); + break; + } + + evaluatedTemplateArr.push(template.slice(currentIndex, openingBraceIndex)); + const closingBraceIndex = template.indexOf("}", openingBraceIndex); + + if (closingBraceIndex === -1) { + // No more closing braces, add the rest of the template and break. + evaluatedTemplateArr.push(template.slice(openingBraceIndex)); + break; + } + + if (template[openingBraceIndex + 1] === "{" && template[closingBraceIndex + 1] === "}") { + // Escaped expression. Do not evaluate. + evaluatedTemplateArr.push(template.slice(openingBraceIndex + 1, closingBraceIndex)); + currentIndex = closingBraceIndex + 2; + } + + const parameterName = template.substring(openingBraceIndex + 1, closingBraceIndex); + + if (parameterName.includes("#")) { + const [refName, attrName] = parameterName.split("#"); + evaluatedTemplateArr.push(getAttr(templateContext[refName], attrName) as string); + } else { + evaluatedTemplateArr.push(templateContext[parameterName]); + } + + currentIndex = closingBraceIndex + 1; + } + + return evaluatedTemplateArr.join(""); +}; diff --git a/packages/util-endpoints/src/utils/evaluateTreeRule.spec.ts b/packages/util-endpoints/src/utils/evaluateTreeRule.spec.ts new file mode 100644 index 00000000000..72881e8a296 --- /dev/null +++ b/packages/util-endpoints/src/utils/evaluateTreeRule.spec.ts @@ -0,0 +1,50 @@ +import { TreeRuleObject } from "../types"; +import { evaluateConditions } from "./evaluateConditions"; +import { evaluateRules } from "./evaluateRules"; +import { evaluateTreeRule } from "./evaluateTreeRule"; + +jest.mock("./evaluateConditions"); +jest.mock("./evaluateRules"); + +describe(evaluateTreeRule.name, () => { + const mockOptions = { + endpointParams: {}, + referenceRecord: {}, + }; + const mockConditions = [ + { fn: "fn1", argv: ["arg1"] }, + { fn: "fn2", argv: ["arg2"] }, + ]; + const mockTreeRule: TreeRuleObject = { + type: "tree", + conditions: mockConditions, + rules: [], + }; + + it("returns undefined if conditions evaluate to false", () => { + (evaluateConditions as jest.Mock).mockReturnValue({ result: false }); + const result = evaluateTreeRule(mockTreeRule, mockOptions); + expect(result).toBeUndefined(); + expect(evaluateConditions).toHaveBeenCalledWith(mockConditions, mockOptions); + expect(evaluateRules).not.toHaveBeenCalled(); + }); + + it("returns evaluateRules if conditions evaluate to true", () => { + const mockReferenceRecord = { key: "value" }; + const mockEndpointUrl = new URL("http://example.com"); + + (evaluateConditions as jest.Mock).mockReturnValue({ result: true, referenceRecord: mockReferenceRecord }); + (evaluateRules as jest.Mock).mockReturnValue(mockEndpointUrl); + + const result = evaluateTreeRule(mockTreeRule, mockOptions); + expect(result).toBe(mockEndpointUrl); + expect(evaluateConditions).toHaveBeenCalledWith(mockConditions, mockOptions); + expect(evaluateRules).toHaveBeenCalledWith(mockTreeRule.rules, { + ...mockOptions, + referenceRecord: { + ...mockOptions.referenceRecord, + ...mockReferenceRecord, + }, + }); + }); +}); diff --git a/packages/util-endpoints/src/utils/evaluateTreeRule.ts b/packages/util-endpoints/src/utils/evaluateTreeRule.ts new file mode 100644 index 00000000000..6c3ec2f09a7 --- /dev/null +++ b/packages/util-endpoints/src/utils/evaluateTreeRule.ts @@ -0,0 +1,19 @@ +import { EndpointV2 } from "@smithy/types"; + +import { EvaluateOptions, TreeRuleObject } from "../types"; +import { evaluateConditions } from "./evaluateConditions"; +import { evaluateRules } from "./evaluateRules"; + +export const evaluateTreeRule = (treeRule: TreeRuleObject, options: EvaluateOptions): EndpointV2 | undefined => { + const { conditions, rules } = treeRule; + + const { result, referenceRecord } = evaluateConditions(conditions, options); + if (!result) { + return; + } + + return evaluateRules(rules, { + ...options, + referenceRecord: { ...options.referenceRecord, ...referenceRecord }, + }); +}; diff --git a/packages/util-endpoints/src/utils/getEndpointHeaders.spec.ts b/packages/util-endpoints/src/utils/getEndpointHeaders.spec.ts new file mode 100644 index 00000000000..7b79f733b1b --- /dev/null +++ b/packages/util-endpoints/src/utils/getEndpointHeaders.spec.ts @@ -0,0 +1,45 @@ +import { evaluateExpression } from "./evaluateExpression"; +import { getEndpointHeaders } from "./getEndpointHeaders"; + +jest.mock("./evaluateExpression"); + +describe(getEndpointHeaders.name, () => { + const mockOptions = { + endpointParams: {}, + referenceRecord: {}, + }; + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should return an empty object if empty headers are provided", () => { + expect(getEndpointHeaders({}, mockOptions)).toEqual({}); + expect(evaluateExpression).not.toHaveBeenCalled(); + }); + + it("should return processed header", () => { + const inputHeaderValue = "inputHeaderValue"; + const outputHeaderValue = "outputHeaderValue"; + const mockHeaders = { key: [inputHeaderValue] }; + + (evaluateExpression as jest.Mock).mockReturnValue(outputHeaderValue); + expect(getEndpointHeaders(mockHeaders, mockOptions)).toEqual({ key: [outputHeaderValue] }); + expect(evaluateExpression).toHaveBeenCalledWith("inputHeaderValue", "Header value entry", mockOptions); + }); + + it.each([null, undefined, true, 1])( + "should throw error if evaluated expression is not string: %s", + (notStringValue) => { + const inputHeaderKey = "inputHeaderKey"; + const inputHeaderValue = "inputHeaderValue"; + const mockHeaders = { [inputHeaderKey]: [inputHeaderValue] }; + + (evaluateExpression as jest.Mock).mockReturnValue(notStringValue); + expect(() => getEndpointHeaders(mockHeaders, mockOptions)).toThrowError( + `Header '${inputHeaderKey}' value '${notStringValue}' is not a string` + ); + expect(evaluateExpression).toHaveBeenCalledWith("inputHeaderValue", "Header value entry", mockOptions); + } + ); +}); diff --git a/packages/util-endpoints/src/utils/getEndpointHeaders.ts b/packages/util-endpoints/src/utils/getEndpointHeaders.ts new file mode 100644 index 00000000000..e349fdaf60f --- /dev/null +++ b/packages/util-endpoints/src/utils/getEndpointHeaders.ts @@ -0,0 +1,17 @@ +import { EndpointError, EndpointObjectHeaders, EvaluateOptions } from "../types"; +import { evaluateExpression } from "./evaluateExpression"; + +export const getEndpointHeaders = (headers: EndpointObjectHeaders, options: EvaluateOptions) => + Object.entries(headers).reduce( + (acc, [headerKey, headerVal]) => ({ + ...acc, + [headerKey]: headerVal.map((headerValEntry) => { + const processedExpr = evaluateExpression(headerValEntry, "Header value entry", options); + if (typeof processedExpr !== "string") { + throw new EndpointError(`Header '${headerKey}' value '${processedExpr}' is not a string`); + } + return processedExpr; + }), + }), + {} + ); diff --git a/packages/util-endpoints/src/utils/getEndpointProperties.spec.ts b/packages/util-endpoints/src/utils/getEndpointProperties.spec.ts new file mode 100644 index 00000000000..f751d157a2e --- /dev/null +++ b/packages/util-endpoints/src/utils/getEndpointProperties.spec.ts @@ -0,0 +1,29 @@ +import { getEndpointProperties } from "./getEndpointProperties"; +import { getEndpointProperty } from "./getEndpointProperty"; + +jest.mock("./getEndpointProperty"); + +describe(getEndpointProperties.name, () => { + const mockOptions = { + endpointParams: {}, + referenceRecord: {}, + }; + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should return an empty object if empty properties are provided", () => { + expect(getEndpointProperties({}, mockOptions)).toEqual({}); + }); + + it("return processed endpoint properties", () => { + const inputPropertyValue = "inputPropertyValue"; + const outputPropertyValue = "outputPropertyValue"; + const mockProperties = { key: inputPropertyValue }; + + (getEndpointProperty as jest.Mock).mockReturnValue(outputPropertyValue); + expect(getEndpointProperties(mockProperties, mockOptions)).toEqual({ key: outputPropertyValue }); + expect(getEndpointProperty).toHaveBeenCalledWith(inputPropertyValue, mockOptions); + }); +}); diff --git a/packages/util-endpoints/src/utils/getEndpointProperties.ts b/packages/util-endpoints/src/utils/getEndpointProperties.ts new file mode 100644 index 00000000000..6b1249c41d2 --- /dev/null +++ b/packages/util-endpoints/src/utils/getEndpointProperties.ts @@ -0,0 +1,11 @@ +import { EndpointObjectProperties, EvaluateOptions } from "../types"; +import { getEndpointProperty } from "./getEndpointProperty"; + +export const getEndpointProperties = (properties: EndpointObjectProperties, options: EvaluateOptions) => + Object.entries(properties).reduce( + (acc, [propertyKey, propertyVal]) => ({ + ...acc, + [propertyKey]: getEndpointProperty(propertyVal, options), + }), + {} + ); diff --git a/packages/util-endpoints/src/utils/getEndpointProperty.spec.ts b/packages/util-endpoints/src/utils/getEndpointProperty.spec.ts new file mode 100644 index 00000000000..ebf8af3d76a --- /dev/null +++ b/packages/util-endpoints/src/utils/getEndpointProperty.spec.ts @@ -0,0 +1,75 @@ +import { EndpointError } from "../types"; +import { evaluateTemplate } from "./evaluateTemplate"; +import { getEndpointProperties } from "./getEndpointProperties"; +import { getEndpointProperty } from "./getEndpointProperty"; + +jest.mock("./evaluateTemplate"); +jest.mock("./getEndpointProperties"); + +describe(getEndpointProperty.name, () => { + const mockOptions = { + endpointParams: {}, + referenceRecord: {}, + }; + + const mockInputString = "mockInputString"; + const mockOutputString = "mockOutputString"; + const mockInputObject = { key: mockInputString }; + const mockOutputObject = { key: mockOutputString }; + const mockBoolean = false; + + beforeEach(() => { + (evaluateTemplate as jest.Mock).mockReturnValue(mockOutputString); + (getEndpointProperties as jest.Mock).mockReturnValue(mockOutputObject); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("processes each property in an array", () => { + const arrayLength = 3; + + it.each([ + ["string array", [Array(arrayLength).fill(mockInputString)], [Array(arrayLength).fill(mockOutputString)]], + ["object array", [Array(arrayLength).fill(mockInputObject)], [Array(arrayLength).fill(mockOutputObject)]], + ["boolean array", [Array(arrayLength).fill(mockBoolean)], [Array(arrayLength).fill(mockBoolean)]], + ])("%s", (desc, inputArray, outputArray) => { + expect(getEndpointProperty(inputArray, mockOptions)).toEqual(outputArray); + }); + }); + + it("returns the evaluated template", () => { + expect(getEndpointProperty(mockInputString, mockOptions)).toEqual(mockOutputString); + expect(evaluateTemplate).toHaveBeenCalledWith(mockInputString, mockOptions); + expect(getEndpointProperties).not.toHaveBeenCalled(); + }); + + it("returns the processed object", () => { + expect(getEndpointProperty(mockInputObject, mockOptions)).toEqual(mockOutputObject); + expect(evaluateTemplate).not.toHaveBeenCalled(); + expect(getEndpointProperties).toHaveBeenCalledWith(mockInputObject, mockOptions); + }); + + it("returns the boolean without processing", () => { + expect(getEndpointProperty(mockBoolean, mockOptions)).toEqual(mockBoolean); + expect(evaluateTemplate).not.toHaveBeenCalled(); + expect(getEndpointProperties).not.toHaveBeenCalled(); + }); + + describe("throws error for unexpected property", () => { + it.each([undefined, 0])("%s", (input) => { + // @ts-ignore Argument is not assignable + expect(() => getEndpointProperty(input, mockOptions)).toThrow( + new EndpointError(`Unexpected endpoint property type: ${typeof input}`) + ); + }); + + it("null", () => { + // @ts-ignore Argument is not assignable + expect(() => getEndpointProperty(null, mockOptions)).toThrow( + new EndpointError(`Unexpected endpoint property: null`) + ); + }); + }); +}); diff --git a/packages/util-endpoints/src/utils/getEndpointProperty.ts b/packages/util-endpoints/src/utils/getEndpointProperty.ts new file mode 100644 index 00000000000..5739b648b00 --- /dev/null +++ b/packages/util-endpoints/src/utils/getEndpointProperty.ts @@ -0,0 +1,27 @@ +import { EndpointObjectProperty } from "@smithy/types"; + +import { EndpointError, EvaluateOptions } from "../types"; +import { evaluateTemplate } from "./evaluateTemplate"; +import { getEndpointProperties } from "./getEndpointProperties"; + +export const getEndpointProperty = ( + property: EndpointObjectProperty, + options: EvaluateOptions +): EndpointObjectProperty => { + if (Array.isArray(property)) { + return property.map((propertyEntry) => getEndpointProperty(propertyEntry, options)); + } + switch (typeof property) { + case "string": + return evaluateTemplate(property, options); + case "object": + if (property === null) { + throw new EndpointError(`Unexpected endpoint property: ${property}`); + } + return getEndpointProperties(property, options); + case "boolean": + return property; + default: + throw new EndpointError(`Unexpected endpoint property type: ${typeof property}`); + } +}; diff --git a/packages/util-endpoints/src/utils/getEndpointUrl.spec.ts b/packages/util-endpoints/src/utils/getEndpointUrl.spec.ts new file mode 100644 index 00000000000..3ef27c66dcc --- /dev/null +++ b/packages/util-endpoints/src/utils/getEndpointUrl.spec.ts @@ -0,0 +1,30 @@ +import { EndpointError } from "../types"; +import { evaluateExpression } from "./evaluateExpression"; +import { getEndpointUrl } from "./getEndpointUrl"; + +jest.mock("./evaluateExpression"); + +describe(getEndpointUrl.name, () => { + const mockEndpointUrlInput = "http://input.example.com"; + const mockEndpointUrlOutput = "http://output.example.com"; + const mockOptions = { + endpointParams: {}, + referenceRecord: {}, + }; + + it("returns URL is expression evaluates to string", () => { + (evaluateExpression as jest.Mock).mockReturnValue(mockEndpointUrlOutput); + const result = getEndpointUrl(mockEndpointUrlInput, mockOptions); + expect(result).toEqual(new URL(mockEndpointUrlOutput)); + expect(evaluateExpression).toHaveBeenCalledWith(mockEndpointUrlInput, "Endpoint URL", mockOptions); + }); + + it("throws error if expression evaluates to non-string", () => { + const mockNotStringOutput = 42; + (evaluateExpression as jest.Mock).mockReturnValue(mockNotStringOutput); + expect(() => getEndpointUrl(mockEndpointUrlInput, mockOptions)).toThrowError( + new EndpointError(`Endpoint URL must be a string, got ${typeof mockNotStringOutput}`) + ); + expect(evaluateExpression).toHaveBeenCalledWith(mockEndpointUrlInput, "Endpoint URL", mockOptions); + }); +}); diff --git a/packages/util-endpoints/src/utils/getEndpointUrl.ts b/packages/util-endpoints/src/utils/getEndpointUrl.ts new file mode 100644 index 00000000000..ca3478826d2 --- /dev/null +++ b/packages/util-endpoints/src/utils/getEndpointUrl.ts @@ -0,0 +1,16 @@ +import { EvaluateOptions, Expression } from "../types"; +import { EndpointError } from "../types"; +import { evaluateExpression } from "./evaluateExpression"; + +export const getEndpointUrl = (endpointUrl: Expression, options: EvaluateOptions): URL => { + const expression = evaluateExpression(endpointUrl, "Endpoint URL", options); + if (typeof expression === "string") { + try { + return new URL(expression); + } catch (error) { + console.error(`Failed to construct URL with ${expression}`, error); + throw error; + } + } + throw new EndpointError(`Endpoint URL must be a string, got ${typeof expression}`); +}; diff --git a/packages/util-endpoints/src/utils/getReferenceValue.spec.ts b/packages/util-endpoints/src/utils/getReferenceValue.spec.ts new file mode 100644 index 00000000000..e881760d65c --- /dev/null +++ b/packages/util-endpoints/src/utils/getReferenceValue.spec.ts @@ -0,0 +1,27 @@ +import { getReferenceValue } from "./getReferenceValue"; + +describe(getReferenceValue.name, () => { + const mockOptions = { + endpointParams: {}, + referenceRecord: {}, + }; + const mockRefName = "mockRefName"; + const mockRefValue = "mockRefValue"; + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("returns reference value if reference exists", () => { + it.each(["endpointParams", "referenceRecord"])("in %s", (key) => { + const mockInput = { ref: mockRefName }; + const mockOptionsWithVal = { ...mockOptions, [key]: { [mockRefName]: mockRefValue } }; + const result = getReferenceValue(mockInput, mockOptionsWithVal); + expect(result).toBe(mockRefValue); + }); + }); + + it("returns undefined if reference does not exist", () => { + expect(getReferenceValue({ ref: mockRefName }, mockOptions)).toBeUndefined(); + }); +}); diff --git a/packages/util-endpoints/src/utils/getReferenceValue.ts b/packages/util-endpoints/src/utils/getReferenceValue.ts new file mode 100644 index 00000000000..43f817f81f9 --- /dev/null +++ b/packages/util-endpoints/src/utils/getReferenceValue.ts @@ -0,0 +1,9 @@ +import { EvaluateOptions, ReferenceObject } from "../types"; + +export const getReferenceValue = ({ ref }: ReferenceObject, options: EvaluateOptions) => { + const referenceRecord = { + ...options.endpointParams, + ...options.referenceRecord, + }; + return referenceRecord[ref]; +}; diff --git a/packages/util-endpoints/src/utils/index.ts b/packages/util-endpoints/src/utils/index.ts new file mode 100644 index 00000000000..b571d021cba --- /dev/null +++ b/packages/util-endpoints/src/utils/index.ts @@ -0,0 +1,2 @@ +export * from "./customEndpointFunctions"; +export * from "./evaluateRules"; diff --git a/packages/util-endpoints/tsconfig.cjs.json b/packages/util-endpoints/tsconfig.cjs.json new file mode 100644 index 00000000000..96198be8164 --- /dev/null +++ b/packages/util-endpoints/tsconfig.cjs.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "outDir": "dist-cjs", + "rootDir": "src" + }, + "extends": "../../tsconfig.cjs.json", + "include": ["src/"] +} diff --git a/packages/util-endpoints/tsconfig.es.json b/packages/util-endpoints/tsconfig.es.json new file mode 100644 index 00000000000..7f162b266e2 --- /dev/null +++ b/packages/util-endpoints/tsconfig.es.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "lib": [], + "outDir": "dist-es", + "rootDir": "src" + }, + "extends": "../../tsconfig.es.json", + "include": ["src/"] +} diff --git a/packages/util-endpoints/tsconfig.types.json b/packages/util-endpoints/tsconfig.types.json new file mode 100644 index 00000000000..6cdf9f52ea0 --- /dev/null +++ b/packages/util-endpoints/tsconfig.types.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "declarationDir": "dist-types", + "rootDir": "src" + }, + "extends": "../../tsconfig.types.json", + "include": ["src/"] +} diff --git a/scripts/build-generated-test-packages.js b/scripts/build-generated-test-packages.js index 3d8834f0c25..89caf8685f8 100644 --- a/scripts/build-generated-test-packages.js +++ b/scripts/build-generated-test-packages.js @@ -60,7 +60,7 @@ const buildAndCopyToNodeModules = async (packageName, codegenDir, nodeModulesDir await spawnProcess("touch", ["yarn.lock"], { cwd: codegenDir }); await spawnProcess("yarn", { cwd: codegenDir }); await spawnProcess("yarn", ["build"], { cwd: codegenDir }); - // After building the package, its packed and copied to node_modules so that + // After building the package, it's packed and copied to node_modules so that // it can be used in integration tests by other packages within the monorepo. await spawnProcess("yarn", ["pack"], { cwd: codegenDir }); await spawnProcess("rm", ["-rf", packageName], { cwd: nodeModulesDir }); diff --git a/yarn.lock b/yarn.lock index 11fd702aef5..7b01dea558d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2447,6 +2447,22 @@ __metadata: languageName: unknown linkType: soft +"@smithy/util-endpoints@workspace:packages/util-endpoints": + version: 0.0.0-use.local + resolution: "@smithy/util-endpoints@workspace:packages/util-endpoints" + dependencies: + "@smithy/node-config-provider": "workspace:^" + "@smithy/types": "workspace:^" + "@tsconfig/recommended": 1.0.1 + "@types/node": ^14.14.31 + concurrently: 7.0.0 + downlevel-dts: 0.10.1 + rimraf: 3.0.2 + tslib: ^2.5.0 + typedoc: 0.23.23 + languageName: unknown + linkType: soft + "@smithy/util-hex-encoding@workspace:^, @smithy/util-hex-encoding@workspace:packages/util-hex-encoding": version: 0.0.0-use.local resolution: "@smithy/util-hex-encoding@workspace:packages/util-hex-encoding"