├── .github └── workflows │ └── test.yaml ├── .gitignore ├── .gitmodules ├── LICENSE ├── Makefile ├── README.md ├── build.sh ├── cmd └── wasm │ └── functions.go ├── go.mod ├── go.sum ├── integration_test.go ├── pkg ├── jsonpath │ ├── config │ │ └── config.go │ ├── filter.go │ ├── jsonpath.go │ ├── parser.go │ ├── parser_test.go │ ├── segment.go │ ├── selector.go │ ├── token │ │ ├── token.go │ │ └── token_test.go │ ├── yaml_eval.go │ ├── yaml_eval_test.go │ ├── yaml_query.go │ └── yaml_query_test.go └── overlay │ ├── apply.go │ ├── apply_test.go │ ├── compare.go │ ├── compare_test.go │ ├── parents.go │ ├── parse.go │ ├── parse_test.go │ ├── schema.go │ ├── testdata │ ├── openapi-overlayed.yaml │ ├── openapi.yaml │ ├── overlay-generated.yaml │ ├── overlay.yaml │ └── removeNote.yaml │ └── validate.go └── web ├── .eslintrc.cjs ├── .gitignore ├── @ └── components │ └── ui │ └── progress.tsx ├── README.md ├── api └── share.ts ├── components.json ├── index.html ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── public ├── favicon.ico ├── og-image-2000x1333.png └── og-image-600x900.png ├── src ├── App.css ├── App.tsx ├── Playground.tsx ├── assets │ ├── openapi.svg │ ├── speakeasy-black.svg │ ├── speakeasy-white.svg │ └── wasm │ │ ├── lib.wasm │ │ └── wasm_exec.js ├── bridge.ts ├── components │ ├── CopyButton.tsx │ ├── Editor.tsx │ ├── FileUpload.tsx │ ├── ShareButton.tsx │ ├── ShareDialog.tsx │ └── ui │ │ ├── button.tsx │ │ ├── dropdown-menu.tsx │ │ └── input.tsx ├── compress.ts ├── defaults.ts ├── index.css ├── lib │ └── utils.ts ├── main.tsx ├── openapi.web.worker.ts ├── types.d.ts └── vite-env.d.ts ├── tailwind.config.js ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | types: 8 | - opened 9 | - edited 10 | - synchronize 11 | - reopened 12 | 13 | jobs: 14 | build: 15 | name: Conventional pull request names 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 19 | - uses: amannn/action-semantic-pull-request@0723387faaf9b38adef4775cd42cfd5155ed6017 # v5.5.3 20 | env: 21 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 22 | build-test: 23 | name: Build & Test 24 | if: ${{ github.event.pull_request.draft == false }} 25 | runs-on: ubuntu-latest 26 | steps: 27 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 28 | with: 29 | submodules: true 30 | - name: Set up Go 31 | uses: actions/setup-go@3041bf56c941b39c61721a86cd11f3bb1338122a # v5.2.0 32 | with: 33 | go-version-file: "go.mod" 34 | - name: Set up Node 35 | uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 36 | with: 37 | node-version: "18.x" 38 | registry-url: "https://registry.npmjs.org" 39 | - name: Set up gotestfmt 40 | run: go install github.com/gotesttools/gotestfmt/v2/cmd/gotestfmt@latest 41 | - name: Build 42 | run: go build ./... 43 | - run: go test -json -v -p 1 ./... | gotestfmt -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | dist 3 | *.iml 4 | .vercel 5 | .env*.local 6 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "jsonpath-compliance-test-suite"] 2 | path = jsonpath-compliance-test-suite 3 | url = git@github.com:jsonpath-standard/jsonpath-compliance-test-suite 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | SOURCE=$(shell find . -iname "*.go") 3 | 4 | 5 | web/src/assets/wasm/lib.wasm: $(SOURCE) 6 | ./build.sh 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | 6 | Speakeasy 7 | 8 | 9 |

Speakeasy

10 |

Build APIs your users love ❤️ with Speakeasy

11 |
12 | Docs Quickstart  //  Join us on Slack 13 |
14 |
15 | 16 |
17 | 18 | # jsonpath 19 | 20 | Go Doc 21 | 22 | This is a full implementation of [RFC 9535](https://datatracker.ietf.org/doc/rfc9535/) 23 | 24 | It is build to be wasm compatible. A playground application is available at [overlay.speakeasy.com](https://overlay.speakeasy.com/) 25 | 26 | Everything within RFC9535 is in scope. Grammars outside RFC 9535 are not in scope. 27 | 28 | ## Installation 29 | 30 | This application is included in the [speakeasy](https://github.com/speakeasy-api/speakeasy) CLI, but is also available as a standalone library. 31 | 32 | ## ABNF grammar 33 | 34 | ``` 35 | jsonpath-query = root-identifier segments 36 | segments = *(S segment) 37 | 38 | B = %x20 / ; Space 39 | %x09 / ; Horizontal tab 40 | %x0A / ; Line feed or New line 41 | %x0D ; Carriage return 42 | S = *B ; optional blank space 43 | root-identifier = "$" 44 | selector = name-selector / 45 | wildcard-selector / 46 | slice-selector / 47 | index-selector / 48 | filter-selector 49 | name-selector = string-literal 50 | 51 | string-literal = %x22 *double-quoted %x22 / ; "string" 52 | %x27 *single-quoted %x27 ; 'string' 53 | 54 | double-quoted = unescaped / 55 | %x27 / ; ' 56 | ESC %x22 / ; \" 57 | ESC escapable 58 | 59 | single-quoted = unescaped / 60 | %x22 / ; " 61 | ESC %x27 / ; \' 62 | ESC escapable 63 | 64 | ESC = %x5C ; \ backslash 65 | 66 | unescaped = %x20-21 / ; see RFC 8259 67 | ; omit 0x22 " 68 | %x23-26 / 69 | ; omit 0x27 ' 70 | %x28-5B / 71 | ; omit 0x5C \ 72 | %x5D-D7FF / 73 | ; skip surrogate code points 74 | %xE000-10FFFF 75 | 76 | escapable = %x62 / ; b BS backspace U+0008 77 | %x66 / ; f FF form feed U+000C 78 | %x6E / ; n LF line feed U+000A 79 | %x72 / ; r CR carriage return U+000D 80 | %x74 / ; t HT horizontal tab U+0009 81 | "/" / ; / slash (solidus) U+002F 82 | "\" / ; \ backslash (reverse solidus) U+005C 83 | (%x75 hexchar) ; uXXXX U+XXXX 84 | 85 | hexchar = non-surrogate / 86 | (high-surrogate "\" %x75 low-surrogate) 87 | non-surrogate = ((DIGIT / "A"/"B"/"C" / "E"/"F") 3HEXDIG) / 88 | ("D" %x30-37 2HEXDIG ) 89 | high-surrogate = "D" ("8"/"9"/"A"/"B") 2HEXDIG 90 | low-surrogate = "D" ("C"/"D"/"E"/"F") 2HEXDIG 91 | 92 | HEXDIG = DIGIT / "A" / "B" / "C" / "D" / "E" / "F" 93 | wildcard-selector = "*" 94 | index-selector = int ; decimal integer 95 | 96 | int = "0" / 97 | (["-"] DIGIT1 *DIGIT) ; - optional 98 | DIGIT1 = %x31-39 ; 1-9 non-zero digit 99 | slice-selector = [start S] ":" S [end S] [":" [S step ]] 100 | 101 | start = int ; included in selection 102 | end = int ; not included in selection 103 | step = int ; default: 1 104 | filter-selector = "?" S logical-expr 105 | logical-expr = logical-or-expr 106 | logical-or-expr = logical-and-expr *(S "||" S logical-and-expr) 107 | ; disjunction 108 | ; binds less tightly than conjunction 109 | logical-and-expr = basic-expr *(S "&&" S basic-expr) 110 | ; conjunction 111 | ; binds more tightly than disjunction 112 | 113 | basic-expr = paren-expr / 114 | comparison-expr / 115 | test-expr 116 | 117 | paren-expr = [logical-not-op S] "(" S logical-expr S ")" 118 | ; parenthesized expression 119 | logical-not-op = "!" ; logical NOT operator 120 | test-expr = [logical-not-op S] 121 | (filter-query / ; existence/non-existence 122 | function-expr) ; LogicalType or NodesType 123 | filter-query = rel-query / jsonpath-query 124 | rel-query = current-node-identifier segments 125 | current-node-identifier = "@" 126 | comparison-expr = comparable S comparison-op S comparable 127 | literal = number / string-literal / 128 | true / false / null 129 | comparable = literal / 130 | singular-query / ; singular query value 131 | function-expr ; ValueType 132 | comparison-op = "==" / "!=" / 133 | "<=" / ">=" / 134 | "<" / ">" 135 | 136 | singular-query = rel-singular-query / abs-singular-query 137 | rel-singular-query = current-node-identifier singular-query-segments 138 | abs-singular-query = root-identifier singular-query-segments 139 | singular-query-segments = *(S (name-segment / index-segment)) 140 | name-segment = ("[" name-selector "]") / 141 | ("." member-name-shorthand) 142 | index-segment = "[" index-selector "]" 143 | number = (int / "-0") [ frac ] [ exp ] ; decimal number 144 | frac = "." 1*DIGIT ; decimal fraction 145 | exp = "e" [ "-" / "+" ] 1*DIGIT ; decimal exponent 146 | true = %x74.72.75.65 ; true 147 | false = %x66.61.6c.73.65 ; false 148 | null = %x6e.75.6c.6c ; null 149 | function-name = function-name-first *function-name-char 150 | function-name-first = LCALPHA 151 | function-name-char = function-name-first / "_" / DIGIT 152 | LCALPHA = %x61-7A ; "a".."z" 153 | 154 | function-expr = function-name "(" S [function-argument 155 | *(S "," S function-argument)] S ")" 156 | function-argument = literal / 157 | filter-query / ; (includes singular-query) 158 | logical-expr / 159 | function-expr 160 | segment = child-segment / descendant-segment 161 | child-segment = bracketed-selection / 162 | ("." 163 | (wildcard-selector / 164 | member-name-shorthand)) 165 | 166 | bracketed-selection = "[" S selector *(S "," S selector) S "]" 167 | 168 | member-name-shorthand = name-first *name-char 169 | name-first = ALPHA / 170 | "_" / 171 | %x80-D7FF / 172 | ; skip surrogate code points 173 | %xE000-10FFFF 174 | name-char = name-first / DIGIT 175 | 176 | DIGIT = %x30-39 ; 0-9 177 | ALPHA = %x41-5A / %x61-7A ; A-Z / a-z 178 | descendant-segment = ".." (bracketed-selection / 179 | wildcard-selector / 180 | member-name-shorthand) 181 | 182 | Figure 2: Collected ABNF of JSONPath Queries 183 | 184 | Figure 3 contains the collected ABNF grammar that defines the syntax 185 | of a JSONPath Normalized Path while also using the rules root- 186 | identifier, ESC, DIGIT, and DIGIT1 from Figure 2. 187 | 188 | normalized-path = root-identifier *(normal-index-segment) 189 | normal-index-segment = "[" normal-selector "]" 190 | normal-selector = normal-name-selector / normal-index-selector 191 | normal-name-selector = %x27 *normal-single-quoted %x27 ; 'string' 192 | normal-single-quoted = normal-unescaped / 193 | ESC normal-escapable 194 | normal-unescaped = ; omit %x0-1F control codes 195 | %x20-26 / 196 | ; omit 0x27 ' 197 | %x28-5B / 198 | ; omit 0x5C \ 199 | %x5D-D7FF / 200 | ; skip surrogate code points 201 | %xE000-10FFFF 202 | 203 | normal-escapable = %x62 / ; b BS backspace U+0008 204 | %x66 / ; f FF form feed U+000C 205 | %x6E / ; n LF line feed U+000A 206 | %x72 / ; r CR carriage return U+000D 207 | %x74 / ; t HT horizontal tab U+0009 208 | "'" / ; ' apostrophe U+0027 209 | "\" / ; \ backslash (reverse solidus) U+005C 210 | (%x75 normal-hexchar) 211 | ; certain values u00xx U+00XX 212 | normal-hexchar = "0" "0" 213 | ( 214 | ("0" %x30-37) / ; "00"-"07" 215 | ; omit U+0008-U+000A BS HT LF 216 | ("0" %x62) / ; "0b" 217 | ; omit U+000C-U+000D FF CR 218 | ("0" %x65-66) / ; "0e"-"0f" 219 | ("1" normal-HEXDIG) 220 | ) 221 | normal-HEXDIG = DIGIT / %x61-66 ; "0"-"9", "a"-"f" 222 | normal-index-selector = "0" / (DIGIT1 *DIGIT) 223 | ; non-negative decimal integer 224 | ``` 225 | 226 | ## Contributing 227 | 228 | We welcome contributions to this repository! Please open a Github issue or a Pull Request if you have an implementation for a bug fix or feature. This repository is compliant with the [jsonpath standard compliance test suite](https://github.com/jsonpath-standard/jsonpath-compliance-test-suite/tree/9277705cda4489c3d0d984831e7656e48145399b) 229 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)" 3 | readonly REPO_ROOT="$(git rev-parse --show-toplevel)" 4 | 5 | # ANSI color codes 6 | GREEN='\033[0;32m' 7 | RED='\033[0;31m' 8 | NC='\033[0m' # No Color 9 | 10 | # Validate Go version higher or equal to than this. 11 | REQUIRED_GO_VERSION="1.24.1" 12 | CURRENT_GO_VERSION=$(go version | awk '{print $3}' | sed 's/go//') 13 | 14 | if ! echo "$CURRENT_GO_VERSION $REQUIRED_GO_VERSION" | awk '{ 15 | split($1, current, ".") 16 | split($2, required, ".") 17 | if ((current[1] > required[1]) || \ 18 | (current[1] == required[1] && current[2] > required[2]) || \ 19 | (current[1] == required[1] && current[2] == required[2] && current[3] >= required[3])) { 20 | exit 0; 21 | } 22 | exit 1 23 | }'; then 24 | echo -e "${RED}Error: Go version must be $REQUIRED_GO_VERSION or higher, but found $CURRENT_GO_VERSION${NC}" 25 | exit 1 26 | fi 27 | 28 | get_file_size() { 29 | if [[ "$OSTYPE" == "darwin"* ]]; then 30 | BYTES=$(stat -f%z "$1") 31 | else 32 | BYTES=$(stat -c%s "$1") 33 | fi 34 | MB=$((BYTES / 1024 / 1024)) 35 | echo "$MB MB" 36 | } 37 | 38 | # Function to build the WASM binary 39 | build_wasm() { 40 | printf "Installing dependencies... " 41 | (cd "$SCRIPT_DIR" && go mod tidy) > /dev/null 2>&1 42 | printf "Done\n" 43 | 44 | printf "Building WASM binary..." 45 | GOOS=js GOARCH=wasm go build -o ./web/src/assets/wasm/lib.wasm cmd/wasm/functions.go 46 | SIZE="$(get_file_size "./web/src/assets/wasm/lib.wasm")" 47 | printf " Done (%s)\n" "$SIZE" 48 | cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" "./web/src/assets/wasm/wasm_exec.js" 49 | 50 | } 51 | 52 | # Initial build 53 | build_wasm "$VERSION" 54 | -------------------------------------------------------------------------------- /cmd/wasm/functions.go: -------------------------------------------------------------------------------- 1 | //go:build js && wasm 2 | 3 | package main 4 | 5 | import ( 6 | "encoding/json" 7 | "fmt" 8 | "github.com/speakeasy-api/jsonpath/pkg/jsonpath" 9 | "github.com/speakeasy-api/jsonpath/pkg/jsonpath/config" 10 | "github.com/speakeasy-api/jsonpath/pkg/jsonpath/token" 11 | "github.com/speakeasy-api/jsonpath/pkg/overlay" 12 | "gopkg.in/yaml.v3" 13 | "reflect" 14 | "syscall/js" 15 | ) 16 | 17 | func CalculateOverlay(originalYAML, targetYAML, existingOverlay string) (string, error) { 18 | var orig yaml.Node 19 | err := yaml.Unmarshal([]byte(originalYAML), &orig) 20 | if err != nil { 21 | return "", fmt.Errorf("failed to parse source schema: %w", err) 22 | } 23 | var target yaml.Node 24 | err = yaml.Unmarshal([]byte(targetYAML), &target) 25 | if err != nil { 26 | return "", fmt.Errorf("failed to parse target schema: %w", err) 27 | } 28 | 29 | // we go from the original to a new version, then look at the extra overlays on top 30 | // of that, then add that to the existing overlay 31 | var existingOverlayDocument overlay.Overlay 32 | err = yaml.Unmarshal([]byte(existingOverlay), &existingOverlayDocument) 33 | if err != nil { 34 | return "", fmt.Errorf("failed to parse overlay schema in CalculateOverlay: %w", err) 35 | } 36 | existingOverlayDocument.JSONPathVersion = "rfc9535" // force this in the playground. 37 | // now modify the original using the existing overlay 38 | err = existingOverlayDocument.ApplyTo(&orig) 39 | if err != nil { 40 | return "", fmt.Errorf("failed to apply existing overlay: %w", err) 41 | } 42 | 43 | newOverlay, err := overlay.Compare("example overlay", &orig, target) 44 | if err != nil { 45 | return "", fmt.Errorf("failed to compare schemas: %w", err) 46 | } 47 | // special case, is there only one action and it targets the same as the last overlayDocument.Actions item entry, we'll just replace it. 48 | if len(newOverlay.Actions) == 1 && len(existingOverlayDocument.Actions) > 0 && newOverlay.Actions[0].Target == existingOverlayDocument.Actions[len(existingOverlayDocument.Actions)-1].Target { 49 | existingOverlayDocument.Actions[len(existingOverlayDocument.Actions)-1] = newOverlay.Actions[0] 50 | } else { 51 | // Otherwise, we'll just append the new overlay to the existing overlay 52 | existingOverlayDocument.Actions = append(existingOverlayDocument.Actions, newOverlay.Actions...) 53 | } 54 | 55 | out, err := yaml.Marshal(existingOverlayDocument) 56 | if err != nil { 57 | return "", fmt.Errorf("failed to marshal schema: %w", err) 58 | } 59 | 60 | return string(out), nil 61 | } 62 | 63 | func GetInfo(originalYAML string) (string, error) { 64 | var orig yaml.Node 65 | err := yaml.Unmarshal([]byte(originalYAML), &orig) 66 | if err != nil { 67 | return "", fmt.Errorf("failed to parse source schema: %w", err) 68 | } 69 | 70 | titlePath, err := jsonpath.NewPath("$.info.title") 71 | if err != nil { 72 | return "", err 73 | } 74 | versionPath, err := jsonpath.NewPath("$.info.version") 75 | if err != nil { 76 | return "", err 77 | } 78 | descriptionPath, err := jsonpath.NewPath("$.info.version") 79 | if err != nil { 80 | return "", err 81 | } 82 | toString := func(node []*yaml.Node) string { 83 | if len(node) == 0 { 84 | return "" 85 | } 86 | return node[0].Value 87 | } 88 | 89 | return `{ 90 | "title": "` + toString(titlePath.Query(&orig)) + `", 91 | "version": "` + toString(versionPath.Query(&orig)) + `", 92 | "description": "` + toString(descriptionPath.Query(&orig)) + `" 93 | }`, nil 94 | } 95 | 96 | type ApplyOverlaySuccess struct { 97 | Type string `json:"type"` 98 | Result string `json:"result"` 99 | } 100 | 101 | func ApplyOverlay(originalYAML, overlayYAML string) (string, error) { 102 | var orig yaml.Node 103 | err := yaml.Unmarshal([]byte(originalYAML), &orig) 104 | if err != nil { 105 | return "", fmt.Errorf("failed to parse original schema: %w", err) 106 | } 107 | 108 | var overlay overlay.Overlay 109 | err = yaml.Unmarshal([]byte(overlayYAML), &overlay) 110 | if err != nil { 111 | return "", fmt.Errorf("failed to parse overlay schema in ApplyOverlay: %w", err) 112 | } 113 | err = overlay.Validate() 114 | if err != nil { 115 | return "", fmt.Errorf("failed to validate overlay schema in ApplyOverlay: %w", err) 116 | } 117 | hasFilterExpression := false 118 | // check to see if we have an overlay with an error, or a partial overlay: i.e. any overlay actions are missing an update or remove 119 | for i, action := range overlay.Actions { 120 | tokenized := token.NewTokenizer(action.Target, config.WithPropertyNameExtension()).Tokenize() 121 | for _, tok := range tokenized { 122 | if tok.Token == token.FILTER { 123 | hasFilterExpression = true 124 | break 125 | } 126 | } 127 | parsed, pathErr := jsonpath.NewPath(action.Target, config.WithPropertyNameExtension()) 128 | 129 | var node *yaml.Node 130 | if pathErr != nil { 131 | node, err = lookupOverlayActionTargetNode(overlayYAML, i) 132 | if err != nil { 133 | return "", err 134 | } 135 | 136 | return applyOverlayJSONPathError(pathErr, node) 137 | } 138 | if reflect.ValueOf(action.Update).IsZero() && action.Remove == false { 139 | result := parsed.Query(&orig) 140 | 141 | node, err = lookupOverlayActionTargetNode(overlayYAML, i) 142 | if err != nil { 143 | return "", err 144 | } 145 | 146 | return applyOverlayJSONPathIncomplete(result, node) 147 | } 148 | } 149 | if hasFilterExpression && overlay.JSONPathVersion != "rfc9535" { 150 | return "", fmt.Errorf("invalid overlay schema: must have `x-speakeasy-jsonpath: rfc9535`") 151 | } 152 | 153 | err = overlay.ApplyTo(&orig) 154 | if err != nil { 155 | return "", fmt.Errorf("failed to apply overlay: %w", err) 156 | } 157 | 158 | // Unwrap the document node if it exists and has only one content node 159 | if orig.Kind == yaml.DocumentNode && len(orig.Content) == 1 { 160 | orig = *orig.Content[0] 161 | } 162 | 163 | out, err := yaml.Marshal(&orig) 164 | if err != nil { 165 | return "", fmt.Errorf("failed to marshal result: %w", err) 166 | } 167 | 168 | out, err = json.Marshal(ApplyOverlaySuccess{ 169 | Type: "success", 170 | Result: string(out), 171 | }) 172 | 173 | return string(out), err 174 | } 175 | 176 | type IncompleteOverlayErrorMessage struct { 177 | Type string `json:"type"` 178 | Line int `json:"line"` 179 | Col int `json:"col"` 180 | Result string `json:"result"` 181 | } 182 | 183 | func applyOverlayJSONPathIncomplete(result []*yaml.Node, node *yaml.Node) (string, error) { 184 | yamlResult, err := yaml.Marshal(&result) 185 | if err != nil { 186 | return "", err 187 | } 188 | 189 | out, err := json.Marshal(IncompleteOverlayErrorMessage{ 190 | Type: "incomplete", 191 | Line: node.Line, 192 | Col: node.Column, 193 | Result: string(yamlResult), 194 | }) 195 | 196 | return string(out), err 197 | } 198 | 199 | type JSONPathErrorMessage struct { 200 | Type string `json:"type"` 201 | Line int `json:"line"` 202 | Col int `json:"col"` 203 | ErrMessage string `json:"error"` 204 | } 205 | 206 | func applyOverlayJSONPathError(err error, node *yaml.Node) (string, error) { 207 | // first lets see if we can find a target expression 208 | out, err := json.Marshal(JSONPathErrorMessage{ 209 | Type: "error", 210 | Line: node.Line, 211 | Col: node.Column, 212 | ErrMessage: err.Error(), 213 | }) 214 | return string(out), err 215 | } 216 | 217 | func lookupOverlayActionTargetNode(overlayYAML string, i int) (*yaml.Node, error) { 218 | var node struct { 219 | Actions []struct { 220 | Target yaml.Node `yaml:"target"` 221 | } `yaml:"actions"` 222 | } 223 | err := yaml.Unmarshal([]byte(overlayYAML), &node) 224 | if err != nil { 225 | return nil, fmt.Errorf("failed to parse overlay schema in lookupOverlayActionTargetNode: %w", err) 226 | } 227 | if len(node.Actions) <= i { 228 | return nil, fmt.Errorf("no action at index %d", i) 229 | } 230 | if reflect.ValueOf(node.Actions[i].Target).IsZero() { 231 | return nil, fmt.Errorf("no target at index %d", i) 232 | } 233 | return &node.Actions[i].Target, nil 234 | } 235 | 236 | func Query(currentYAML, path string) (string, error) { 237 | var orig yaml.Node 238 | err := yaml.Unmarshal([]byte(currentYAML), &orig) 239 | if err != nil { 240 | return "", fmt.Errorf("failed to parse original schema in Query: %w", err) 241 | } 242 | parsed, err := jsonpath.NewPath(path, config.WithPropertyNameExtension()) 243 | if err != nil { 244 | return "", err 245 | } 246 | result := parsed.Query(&orig) 247 | // Marshal it back out 248 | out, err := yaml.Marshal(result) 249 | if err != nil { 250 | return "", err 251 | } 252 | return string(out), nil 253 | } 254 | 255 | func promisify(fn func(args []js.Value) (string, error)) js.Func { 256 | return js.FuncOf(func(this js.Value, args []js.Value) any { 257 | // Handler for the Promise 258 | handler := js.FuncOf(func(this js.Value, promiseArgs []js.Value) interface{} { 259 | resolve := promiseArgs[0] 260 | reject := promiseArgs[1] 261 | 262 | // Run this code asynchronously 263 | go func() { 264 | result, err := fn(args) 265 | if err != nil { 266 | errorConstructor := js.Global().Get("Error") 267 | errorObject := errorConstructor.New(err.Error()) 268 | reject.Invoke(errorObject) 269 | return 270 | } 271 | 272 | resolve.Invoke(result) 273 | }() 274 | 275 | // The handler of a Promise doesn't return any value 276 | return nil 277 | }) 278 | 279 | // Create and return the Promise object 280 | promiseConstructor := js.Global().Get("Promise") 281 | return promiseConstructor.New(handler) 282 | }) 283 | } 284 | 285 | func main() { 286 | js.Global().Set("CalculateOverlay", promisify(func(args []js.Value) (string, error) { 287 | if len(args) != 3 { 288 | return "", fmt.Errorf("CalculateOverlay: expected 3 args, got %v", len(args)) 289 | } 290 | 291 | return CalculateOverlay(args[0].String(), args[1].String(), args[2].String()) 292 | })) 293 | 294 | js.Global().Set("ApplyOverlay", promisify(func(args []js.Value) (string, error) { 295 | if len(args) != 2 { 296 | return "", fmt.Errorf("ApplyOverlay: expected 2 args, got %v", len(args)) 297 | } 298 | 299 | return ApplyOverlay(args[0].String(), args[1].String()) 300 | })) 301 | 302 | js.Global().Set("GetInfo", promisify(func(args []js.Value) (string, error) { 303 | if len(args) != 1 { 304 | return "", fmt.Errorf("GetInfo: expected 1 arg, got %v", len(args)) 305 | } 306 | 307 | return GetInfo(args[0].String()) 308 | })) 309 | js.Global().Set("QueryJSONPath", promisify(func(args []js.Value) (string, error) { 310 | if len(args) != 1 { 311 | return "", fmt.Errorf("Query: expected 2 args, got %v", len(args)) 312 | } 313 | 314 | return Query(args[0].String(), args[1].String()) 315 | })) 316 | 317 | <-make(chan bool) 318 | } 319 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/speakeasy-api/jsonpath 2 | 3 | go 1.22 4 | 5 | require ( 6 | github.com/pmezard/go-difflib v1.0.0 7 | github.com/stretchr/testify v1.9.0 8 | gopkg.in/yaml.v3 v3.0.1 9 | ) 10 | 11 | require ( 12 | github.com/davecgh/go-spew v1.1.1 // indirect 13 | github.com/kr/pretty v0.1.0 // indirect 14 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect 15 | ) 16 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= 4 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 5 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 6 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 7 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 8 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 9 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 10 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 11 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 12 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 13 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= 14 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 15 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 16 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 17 | -------------------------------------------------------------------------------- /integration_test.go: -------------------------------------------------------------------------------- 1 | package jsonpath_test 2 | 3 | import ( 4 | "encoding/json" 5 | "github.com/pmezard/go-difflib/difflib" 6 | "github.com/speakeasy-api/jsonpath/pkg/jsonpath" 7 | "github.com/stretchr/testify/require" 8 | "gopkg.in/yaml.v3" 9 | "os" 10 | "slices" 11 | "strings" 12 | "testing" 13 | ) 14 | 15 | type FullTestSuite struct { 16 | Description string `json:"description"` 17 | Tests []Test `json:"tests"` 18 | } 19 | type Test struct { 20 | Name string `json:"name"` 21 | Selector string `json:"selector"` 22 | Document interface{} `json:"document"` 23 | Result []interface{} `json:"result"` 24 | Results [][]interface{} `json:"results"` 25 | InvalidSelector bool `json:"invalid_selector"` 26 | Tags []string `json:"tags"` 27 | } 28 | 29 | func TestJSONPathComplianceTestSuite(t *testing.T) { 30 | // Read the test suite JSON file 31 | file, err := os.ReadFile("./jsonpath-compliance-test-suite/cts.json") 32 | require.NoError(t, err, "Failed to read test suite file") 33 | // alter the file to delete any unicode tests: these break the yaml library we use.. 34 | var testSuite FullTestSuite 35 | json.Unmarshal(file, &testSuite) 36 | for i := 0; i < len(testSuite.Tests); i++ { 37 | // if Tags contains "unicode", delete it 38 | // (they break the yaml parser) 39 | shouldDelete := slices.Contains(testSuite.Tests[i].Tags, "unicode") 40 | // delete some other new line / unicode tests -- these also break the yaml parser 41 | shouldDelete = shouldDelete || strings.Contains(testSuite.Tests[i].Name, "line feed") 42 | shouldDelete = shouldDelete || strings.Contains(testSuite.Tests[i].Name, "carriage return") 43 | shouldDelete = shouldDelete || strings.Contains(testSuite.Tests[i].Name, "u2028") 44 | shouldDelete = shouldDelete || strings.Contains(testSuite.Tests[i].Name, "u2029") 45 | if shouldDelete { 46 | testSuite.Tests = append(testSuite.Tests[:i], testSuite.Tests[i+1:]...) 47 | i-- 48 | } 49 | } 50 | 51 | // Run each test case as a subtest 52 | for _, test := range testSuite.Tests { 53 | t.Run(test.Name, func(t *testing.T) { 54 | // Test case for a valid selector 55 | jp, err := jsonpath.NewPath(test.Selector) 56 | if test.InvalidSelector { 57 | require.Error(t, err, "Expected an error for invalid selector, but got none for path", jp.String()) 58 | return 59 | } else { 60 | require.NoError(t, err, "Failed to parse JSONPath selector", jp.String()) 61 | } 62 | 63 | // expect stability of ToString() 64 | stringified := jp.String() 65 | recursive, err := jsonpath.NewPath(stringified) 66 | require.NoError(t, err, "Failed to parse recursive JSONPath selector. expected=%s got=%s", test.Selector, jp.String()) 67 | require.Equal(t, stringified, recursive.String(), "JSONPath selector does not match test case") 68 | // interface{} to yaml.Node 69 | toYAML := func(i interface{}) *yaml.Node { 70 | o, err := yaml.Marshal(i) 71 | require.NoError(t, err, "Failed to marshal interface to yaml") 72 | n := new(yaml.Node) 73 | err = yaml.Unmarshal(o, n) 74 | require.NoError(t, err, "Failed to unmarshal yaml to yaml.Node") 75 | // unwrap the document node 76 | if n.Kind == yaml.DocumentNode && len(n.Content) == 1 { 77 | n = n.Content[0] 78 | } 79 | return n 80 | } 81 | 82 | result := jp.Query(toYAML(test.Document)) 83 | 84 | if test.Results != nil { 85 | expectedResults := make([][]*yaml.Node, 0) 86 | for _, expectedResult := range test.Results { 87 | expected := make([]*yaml.Node, 0) 88 | for _, expectedResult := range expectedResult { 89 | expected = append(expected, toYAML(expectedResult)) 90 | } 91 | expectedResults = append(expectedResults, expected) 92 | } 93 | 94 | // Test case with multiple possible results 95 | var found bool 96 | for i, _ := range test.Results { 97 | if match, msg := compareResults(result, expectedResults[i]); match { 98 | found = true 99 | break 100 | } else { 101 | t.Log(msg) 102 | } 103 | } 104 | if !found { 105 | t.Errorf("Unexpected result. Got: %v, Want one of: %v", result, test.Results) 106 | } 107 | } else { 108 | expectedResult := make([]*yaml.Node, 0) 109 | for _, res := range test.Result { 110 | expectedResult = append(expectedResult, toYAML(res)) 111 | } 112 | // Test case with a single expected result 113 | if match, msg := compareResults(result, expectedResult); !match { 114 | t.Error(msg) 115 | } 116 | } 117 | }) 118 | } 119 | } 120 | 121 | func compareResults(actual, expected []*yaml.Node) (bool, string) { 122 | actualStr, err := yaml.Marshal(actual) 123 | if err != nil { 124 | return false, "Failed to serialize actual result: " + err.Error() 125 | } 126 | 127 | expectedStr, err := yaml.Marshal(expected) 128 | if err != nil { 129 | return false, "Failed to serialize expected result: " + err.Error() 130 | } 131 | 132 | if string(actualStr) == string(expectedStr) { 133 | return true, "" 134 | } 135 | 136 | // Generate a nice diff string 137 | diff := difflib.UnifiedDiff{ 138 | A: difflib.SplitLines(string(expectedStr)), 139 | B: difflib.SplitLines(string(actualStr)), 140 | FromFile: "Expected", 141 | ToFile: "Actual", 142 | Context: 3, 143 | } 144 | diffStr, err := difflib.GetUnifiedDiffString(diff) 145 | if err != nil { 146 | return false, "Failed to generate diff: " + err.Error() 147 | } 148 | 149 | return false, diffStr 150 | } 151 | -------------------------------------------------------------------------------- /pkg/jsonpath/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | type Option func(*config) 4 | 5 | // WithPropertyNameExtension enables the use of the "~" character to access a property key. 6 | // It is not enabled by default as this is outside of RFC 9535, but is important for several use-cases 7 | func WithPropertyNameExtension() Option { 8 | return func(cfg *config) { 9 | cfg.propertyNameExtension = true 10 | } 11 | } 12 | 13 | type Config interface { 14 | PropertyNameEnabled() bool 15 | } 16 | 17 | type config struct { 18 | propertyNameExtension bool 19 | } 20 | 21 | func (c *config) PropertyNameEnabled() bool { 22 | return c.propertyNameExtension 23 | } 24 | 25 | func New(opts ...Option) Config { 26 | cfg := &config{} 27 | for _, opt := range opts { 28 | opt(cfg) 29 | } 30 | return cfg 31 | } 32 | -------------------------------------------------------------------------------- /pkg/jsonpath/filter.go: -------------------------------------------------------------------------------- 1 | package jsonpath 2 | 3 | import ( 4 | "gopkg.in/yaml.v3" 5 | "strconv" 6 | "strings" 7 | ) 8 | 9 | // filter-selector = "?" S logical-expr 10 | type filterSelector struct { 11 | // logical-expr = logical-or-expr 12 | expression *logicalOrExpr 13 | } 14 | 15 | func (s filterSelector) ToString() string { 16 | return s.expression.ToString() 17 | } 18 | 19 | // logical-or-expr = logical-and-expr *(S "||" S logical-and-expr) 20 | type logicalOrExpr struct { 21 | expressions []*logicalAndExpr 22 | } 23 | 24 | func (e logicalOrExpr) ToString() string { 25 | builder := strings.Builder{} 26 | for i, expr := range e.expressions { 27 | if i > 0 { 28 | builder.WriteString(" || ") 29 | } 30 | builder.WriteString(expr.ToString()) 31 | } 32 | return builder.String() 33 | } 34 | 35 | // logical-and-expr = basic-expr *(S "&&" S basic-expr) 36 | type logicalAndExpr struct { 37 | expressions []*basicExpr 38 | } 39 | 40 | func (e logicalAndExpr) ToString() string { 41 | builder := strings.Builder{} 42 | for i, expr := range e.expressions { 43 | if i > 0 { 44 | builder.WriteString(" && ") 45 | } 46 | builder.WriteString(expr.ToString()) 47 | } 48 | return builder.String() 49 | } 50 | 51 | // relQuery rel-query = current-node-identifier segments 52 | // current-node-identifier = "@" 53 | type relQuery struct { 54 | segments []*segment 55 | } 56 | 57 | func (q relQuery) ToString() string { 58 | builder := strings.Builder{} 59 | builder.WriteString("@") 60 | for _, segment := range q.segments { 61 | builder.WriteString(segment.ToString()) 62 | } 63 | return builder.String() 64 | } 65 | 66 | // filterQuery filter-query = rel-query / jsonpath-query 67 | type filterQuery struct { 68 | relQuery *relQuery 69 | jsonPathQuery *jsonPathAST 70 | } 71 | 72 | func (q filterQuery) ToString() string { 73 | if q.relQuery != nil { 74 | return q.relQuery.ToString() 75 | } else if q.jsonPathQuery != nil { 76 | return q.jsonPathQuery.ToString() 77 | } 78 | return "" 79 | } 80 | 81 | // functionArgument function-argument = literal / 82 | // 83 | // filter-query / ; (includes singular-query) 84 | // logical-expr / 85 | // function-expr 86 | type functionArgument struct { 87 | literal *literal 88 | filterQuery *filterQuery 89 | logicalExpr *logicalOrExpr 90 | functionExpr *functionExpr 91 | } 92 | 93 | type functionArgType int 94 | 95 | const ( 96 | functionArgTypeLiteral functionArgType = iota 97 | functionArgTypeNodes 98 | ) 99 | 100 | type resolvedArgument struct { 101 | kind functionArgType 102 | literal *literal 103 | nodes []*literal 104 | } 105 | 106 | func (a functionArgument) Eval(idx index, node *yaml.Node, root *yaml.Node) resolvedArgument { 107 | if a.literal != nil { 108 | return resolvedArgument{kind: functionArgTypeLiteral, literal: a.literal} 109 | } else if a.filterQuery != nil { 110 | result := a.filterQuery.Query(idx, node, root) 111 | lits := make([]*literal, len(result)) 112 | for i, node := range result { 113 | lit := nodeToLiteral(node) 114 | lits[i] = &lit 115 | } 116 | if len(result) != 1 { 117 | return resolvedArgument{kind: functionArgTypeNodes, nodes: lits} 118 | } else { 119 | return resolvedArgument{kind: functionArgTypeLiteral, literal: lits[0]} 120 | } 121 | } else if a.logicalExpr != nil { 122 | res := a.logicalExpr.Matches(idx, node, root) 123 | return resolvedArgument{kind: functionArgTypeLiteral, literal: &literal{bool: &res}} 124 | } else if a.functionExpr != nil { 125 | res := a.functionExpr.Evaluate(idx, node, root) 126 | return resolvedArgument{kind: functionArgTypeLiteral, literal: &res} 127 | } 128 | return resolvedArgument{} 129 | } 130 | 131 | func (a functionArgument) ToString() string { 132 | builder := strings.Builder{} 133 | if a.literal != nil { 134 | builder.WriteString(a.literal.ToString()) 135 | } else if a.filterQuery != nil { 136 | builder.WriteString(a.filterQuery.ToString()) 137 | } else if a.logicalExpr != nil { 138 | builder.WriteString(a.logicalExpr.ToString()) 139 | } else if a.functionExpr != nil { 140 | builder.WriteString(a.functionExpr.ToString()) 141 | } 142 | return builder.String() 143 | } 144 | 145 | //function-name = function-name-first *function-name-char 146 | //function-name-first = LCALPHA 147 | //function-name-char = function-name-first / "_" / DIGIT 148 | //LCALPHA = %x61-7A ; "a".."z" 149 | // 150 | 151 | type functionType int 152 | 153 | const ( 154 | functionTypeLength functionType = iota 155 | functionTypeCount 156 | functionTypeMatch 157 | functionTypeSearch 158 | functionTypeValue 159 | ) 160 | 161 | var functionTypeMap = map[string]functionType{ 162 | "length": functionTypeLength, 163 | "count": functionTypeCount, 164 | "match": functionTypeMatch, 165 | "search": functionTypeSearch, 166 | "value": functionTypeValue, 167 | } 168 | 169 | func (f functionType) String() string { 170 | for k, v := range functionTypeMap { 171 | if v == f { 172 | return k 173 | } 174 | } 175 | return "unknown" 176 | } 177 | 178 | // functionExpr function-expr = function-name "(" S [function-argument 179 | // *(S "," S function-argument)] S ")" 180 | type functionExpr struct { 181 | funcType functionType 182 | args []*functionArgument 183 | } 184 | 185 | func (e functionExpr) ToString() string { 186 | builder := strings.Builder{} 187 | builder.WriteString(e.funcType.String()) 188 | builder.WriteString("(") 189 | for i, arg := range e.args { 190 | if i > 0 { 191 | builder.WriteString(", ") 192 | } 193 | builder.WriteString(arg.ToString()) 194 | } 195 | builder.WriteString(")") 196 | return builder.String() 197 | } 198 | 199 | // testExpr test-expr = [logical-not-op S] 200 | // 201 | // (filter-query / ; existence/non-existence 202 | // function-expr) ; LogicalType or NodesType 203 | type testExpr struct { 204 | not bool 205 | filterQuery *filterQuery 206 | functionExpr *functionExpr 207 | } 208 | 209 | func (e testExpr) ToString() string { 210 | builder := strings.Builder{} 211 | if e.not { 212 | builder.WriteString("!") 213 | } 214 | if e.filterQuery != nil { 215 | builder.WriteString(e.filterQuery.ToString()) 216 | } else if e.functionExpr != nil { 217 | builder.WriteString(e.functionExpr.ToString()) 218 | } 219 | return builder.String() 220 | } 221 | 222 | // basicExpr basic-expr = 223 | // 224 | // paren-expr / 225 | // comparison-expr / 226 | // test-expr 227 | type basicExpr struct { 228 | parenExpr *parenExpr 229 | comparisonExpr *comparisonExpr 230 | testExpr *testExpr 231 | } 232 | 233 | func (e basicExpr) ToString() string { 234 | if e.parenExpr != nil { 235 | return e.parenExpr.ToString() 236 | } else if e.comparisonExpr != nil { 237 | return e.comparisonExpr.ToString() 238 | } else if e.testExpr != nil { 239 | return e.testExpr.ToString() 240 | } 241 | return "" 242 | } 243 | 244 | // literal literal = number / 245 | // . string-literal / 246 | // . true / false / null 247 | type literal struct { 248 | // we generally decompose these into their component parts for easier evaluation 249 | integer *int 250 | float64 *float64 251 | string *string 252 | bool *bool 253 | null *bool 254 | node *yaml.Node 255 | } 256 | 257 | func (l literal) ToString() string { 258 | if l.integer != nil { 259 | return strconv.Itoa(*l.integer) 260 | } else if l.float64 != nil { 261 | return strconv.FormatFloat(*l.float64, 'f', -1, 64) 262 | } else if l.string != nil { 263 | builder := strings.Builder{} 264 | builder.WriteString("'") 265 | builder.WriteString(escapeString(*l.string)) 266 | builder.WriteString("'") 267 | return builder.String() 268 | } else if l.bool != nil { 269 | if *l.bool { 270 | return "true" 271 | } else { 272 | return "false" 273 | } 274 | } else if l.null != nil { 275 | if *l.null { 276 | return "null" 277 | } else { 278 | return "null" 279 | } 280 | } else if l.node != nil { 281 | switch l.node.Kind { 282 | case yaml.ScalarNode: 283 | return l.node.Value 284 | case yaml.SequenceNode: 285 | builder := strings.Builder{} 286 | builder.WriteString("[") 287 | for i, child := range l.node.Content { 288 | if i > 0 { 289 | builder.WriteString(",") 290 | } 291 | builder.WriteString(literal{node: child}.ToString()) 292 | } 293 | builder.WriteString("]") 294 | return builder.String() 295 | case yaml.MappingNode: 296 | builder := strings.Builder{} 297 | builder.WriteString("{") 298 | for i, child := range l.node.Content { 299 | if i > 0 { 300 | builder.WriteString(",") 301 | } 302 | builder.WriteString(literal{node: child}.ToString()) 303 | } 304 | builder.WriteString("}") 305 | return builder.String() 306 | } 307 | } 308 | return "" 309 | } 310 | 311 | func escapeString(value string) string { 312 | b := strings.Builder{} 313 | for i := 0; i < len(value); i++ { 314 | if value[i] == '\n' { 315 | b.WriteString("\\\\n") 316 | } else if value[i] == '\\' { 317 | b.WriteString("\\\\") 318 | } else if value[i] == '\'' { 319 | b.WriteString("\\'") 320 | } else { 321 | b.WriteByte(value[i]) 322 | } 323 | } 324 | return b.String() 325 | } 326 | 327 | type absQuery jsonPathAST 328 | 329 | func (q absQuery) ToString() string { 330 | builder := strings.Builder{} 331 | builder.WriteString("$") 332 | for _, segment := range q.segments { 333 | builder.WriteString(segment.ToString()) 334 | } 335 | return builder.String() 336 | } 337 | 338 | // singularQuery singular-query = rel-singular-query / abs-singular-query 339 | type singularQuery struct { 340 | relQuery *relQuery 341 | absQuery *absQuery 342 | } 343 | 344 | func (q singularQuery) ToString() string { 345 | if q.relQuery != nil { 346 | return q.relQuery.ToString() 347 | } else if q.absQuery != nil { 348 | return q.absQuery.ToString() 349 | } 350 | return "" 351 | } 352 | 353 | // comparable 354 | // 355 | // comparable = literal / 356 | // singular-query / ; singular query value 357 | // function-expr ; ValueType 358 | type comparable struct { 359 | literal *literal 360 | singularQuery *singularQuery 361 | functionExpr *functionExpr 362 | } 363 | 364 | func (c comparable) ToString() string { 365 | if c.literal != nil { 366 | return c.literal.ToString() 367 | } else if c.singularQuery != nil { 368 | return c.singularQuery.ToString() 369 | } else if c.functionExpr != nil { 370 | return c.functionExpr.ToString() 371 | } 372 | return "" 373 | } 374 | 375 | // comparisonExpr represents a comparison expression 376 | // 377 | // comparison-expr = comparable S comparison-op S comparable 378 | // literal = number / string-literal / 379 | // true / false / null 380 | // comparable = literal / 381 | // singular-query / ; singular query value 382 | // function-expr ; ValueType 383 | // comparison-op = "==" / "!=" / 384 | // "<=" / ">=" / 385 | // "<" / ">" 386 | type comparisonExpr struct { 387 | left *comparable 388 | op comparisonOperator 389 | right *comparable 390 | } 391 | 392 | func (e comparisonExpr) ToString() string { 393 | builder := strings.Builder{} 394 | builder.WriteString(e.left.ToString()) 395 | builder.WriteString(" ") 396 | builder.WriteString(e.op.ToString()) 397 | builder.WriteString(" ") 398 | builder.WriteString(e.right.ToString()) 399 | return builder.String() 400 | } 401 | 402 | // existExpr represents an existence expression 403 | type existExpr struct { 404 | query string 405 | } 406 | 407 | // parenExpr represents a parenthesized expression 408 | // 409 | // paren-expr = [logical-not-op S] "(" S logical-expr S ")" 410 | type parenExpr struct { 411 | // "!" 412 | not bool 413 | // "(" logicalOrExpr ")" 414 | expr *logicalOrExpr 415 | } 416 | 417 | func (e parenExpr) ToString() string { 418 | builder := strings.Builder{} 419 | if e.not { 420 | builder.WriteString("!") 421 | } 422 | builder.WriteString("(") 423 | builder.WriteString(e.expr.ToString()) 424 | builder.WriteString(")") 425 | return builder.String() 426 | } 427 | 428 | // comparisonOperator represents a comparison operator 429 | type comparisonOperator int 430 | 431 | const ( 432 | equalTo comparisonOperator = iota 433 | notEqualTo 434 | lessThan 435 | lessThanEqualTo 436 | greaterThan 437 | greaterThanEqualTo 438 | ) 439 | 440 | func (o comparisonOperator) ToString() string { 441 | switch o { 442 | case equalTo: 443 | return "==" 444 | case notEqualTo: 445 | return "!=" 446 | case lessThan: 447 | return "<" 448 | case lessThanEqualTo: 449 | return "<=" 450 | case greaterThan: 451 | return ">" 452 | case greaterThanEqualTo: 453 | return ">=" 454 | } 455 | return "" 456 | } 457 | -------------------------------------------------------------------------------- /pkg/jsonpath/jsonpath.go: -------------------------------------------------------------------------------- 1 | package jsonpath 2 | 3 | import ( 4 | "fmt" 5 | "github.com/speakeasy-api/jsonpath/pkg/jsonpath/config" 6 | "github.com/speakeasy-api/jsonpath/pkg/jsonpath/token" 7 | "gopkg.in/yaml.v3" 8 | ) 9 | 10 | func NewPath(input string, opts ...config.Option) (*JSONPath, error) { 11 | tokenizer := token.NewTokenizer(input, opts...) 12 | tokens := tokenizer.Tokenize() 13 | for i := 0; i < len(tokens); i++ { 14 | if tokens[i].Token == token.ILLEGAL { 15 | return nil, fmt.Errorf(tokenizer.ErrorString(&tokens[i], "unexpected token")) 16 | } 17 | } 18 | parser := newParserPrivate(tokenizer, tokens, opts...) 19 | err := parser.parse() 20 | if err != nil { 21 | return nil, err 22 | } 23 | return parser, nil 24 | } 25 | 26 | func (p *JSONPath) Query(root *yaml.Node) []*yaml.Node { 27 | return p.ast.Query(root, root) 28 | } 29 | 30 | func (p *JSONPath) String() string { 31 | if p == nil { 32 | return "" 33 | } 34 | return p.ast.ToString() 35 | } 36 | -------------------------------------------------------------------------------- /pkg/jsonpath/parser_test.go: -------------------------------------------------------------------------------- 1 | package jsonpath_test 2 | 3 | import ( 4 | "github.com/speakeasy-api/jsonpath/pkg/jsonpath" 5 | "github.com/speakeasy-api/jsonpath/pkg/jsonpath/config" 6 | "github.com/stretchr/testify/require" 7 | "testing" 8 | ) 9 | 10 | func TestParser(t *testing.T) { 11 | tests := []struct { 12 | name string 13 | input string 14 | invalid bool 15 | }{ 16 | { 17 | name: "Root node", 18 | input: "$", 19 | }, 20 | { 21 | name: "Single Dot child", 22 | input: "$.store", 23 | }, 24 | { 25 | name: "Single Bracket child", 26 | input: "$['store']", 27 | }, 28 | { 29 | name: "Bracket child", 30 | input: "$['store']['book']", 31 | }, 32 | { 33 | name: "Array index", 34 | input: "$[0]", 35 | }, 36 | { 37 | name: "Array slice", 38 | input: "$[1:3]", 39 | }, 40 | { 41 | name: "Array slice with step", 42 | input: "$[0:5:2]", 43 | }, 44 | { 45 | name: "Array slice with negative step", 46 | input: "$[5:1:-2]", 47 | }, 48 | { 49 | name: "Filter expression", 50 | input: "$[?(@.price < 10)]", 51 | }, 52 | { 53 | name: "Nested filter expression", 54 | input: "$[?(@.price < 10 && @.category == 'fiction')]", 55 | }, 56 | { 57 | name: "Function call", 58 | input: "$.books[?(length(@) > 100)]", 59 | }, 60 | { 61 | name: "Invalid missing closing ]", 62 | input: "$.paths.['/pet'", 63 | invalid: true, 64 | }, 65 | { 66 | name: "Invalid extra input", 67 | input: "$.paths.['/pet')", 68 | invalid: true, 69 | }, 70 | { 71 | name: "Valid filter", 72 | input: "$.paths[?(1 == 1)]", 73 | }, 74 | { 75 | name: "Invalid filter", 76 | input: "$.paths[?(true]", 77 | invalid: true, 78 | }, 79 | } 80 | 81 | for _, test := range tests { 82 | t.Run(test.name, func(t *testing.T) { 83 | path, err := jsonpath.NewPath(test.input) 84 | if test.invalid { 85 | require.Error(t, err) 86 | return 87 | } 88 | require.NoError(t, err) 89 | require.Equal(t, test.input, path.String()) 90 | }) 91 | } 92 | } 93 | 94 | func TestParserPropertyNameExtension(t *testing.T) { 95 | tests := []struct { 96 | name string 97 | input string 98 | enabled bool 99 | valid bool 100 | }{ 101 | { 102 | name: "Simple property name disabled", 103 | input: "$.store~", 104 | enabled: false, 105 | valid: false, 106 | }, 107 | { 108 | name: "Simple property name enabled", 109 | input: "$.store~", 110 | enabled: true, 111 | valid: true, 112 | }, 113 | { 114 | name: "Property name in filter disabled", 115 | input: "$[?(@~)]", 116 | enabled: false, 117 | valid: false, 118 | }, 119 | { 120 | name: "Property name in filter enabled", 121 | input: "$[?(@~)]", 122 | enabled: true, 123 | valid: true, 124 | }, 125 | { 126 | name: "Property name with bracket notation enabled", 127 | input: "$['store']~", 128 | enabled: true, 129 | valid: true, 130 | }, 131 | { 132 | name: "Property name with bracket notation disabled", 133 | input: "$['store']~", 134 | enabled: false, 135 | valid: false, 136 | }, 137 | { 138 | name: "Chained property names enabled", 139 | input: "$.store~.name~", 140 | enabled: true, 141 | valid: true, 142 | }, 143 | { 144 | name: "Property name in complex filter enabled", 145 | input: "$[?(@~ && @.price < 10)]", 146 | enabled: true, 147 | valid: true, 148 | }, 149 | { 150 | name: "Property name in complex filter disabled", 151 | input: "$[?(@~ && @.price < 10)]", 152 | enabled: false, 153 | valid: false, 154 | }, 155 | { 156 | name: "Missing closing a filter expression shouldn't crash", 157 | input: "$.paths.*.*[?(!@.servers)", 158 | enabled: false, 159 | valid: false, 160 | }, 161 | { 162 | name: "Missing closing a filter expression shouldn't crash", 163 | input: "$.paths.*.*[?(!@.servers)", 164 | enabled: false, 165 | valid: false, 166 | }, 167 | { 168 | name: "Missing closing a array crash", 169 | input: "$.paths.*[?@[\"x-my-ignore\"]", 170 | enabled: false, 171 | valid: false, 172 | }, 173 | } 174 | 175 | for _, test := range tests { 176 | t.Run(test.name, func(t *testing.T) { 177 | var opts []config.Option 178 | if test.enabled { 179 | opts = append(opts, config.WithPropertyNameExtension()) 180 | } 181 | 182 | path, err := jsonpath.NewPath(test.input, opts...) 183 | if !test.valid { 184 | require.Error(t, err) 185 | return 186 | } 187 | require.NoError(t, err) 188 | require.Equal(t, test.input, path.String()) 189 | }) 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /pkg/jsonpath/segment.go: -------------------------------------------------------------------------------- 1 | package jsonpath 2 | 3 | import ( 4 | "gopkg.in/yaml.v3" 5 | "strings" 6 | ) 7 | 8 | type segmentKind int 9 | 10 | const ( 11 | segmentKindChild segmentKind = iota // . 12 | segmentKindDescendant // .. 13 | segmentKindProperyName // ~ (extension only) 14 | ) 15 | 16 | type segment struct { 17 | kind segmentKind 18 | child *innerSegment 19 | descendant *innerSegment 20 | } 21 | 22 | type segmentSubKind int 23 | 24 | const ( 25 | segmentDotWildcard segmentSubKind = iota // .* 26 | segmentDotMemberName // .property 27 | segmentLongHand // [ selector[] ] 28 | ) 29 | 30 | func (s segment) ToString() string { 31 | switch s.kind { 32 | case segmentKindChild: 33 | if s.child.kind != segmentLongHand { 34 | return "." + s.child.ToString() 35 | } else { 36 | return s.child.ToString() 37 | } 38 | case segmentKindDescendant: 39 | return ".." + s.descendant.ToString() 40 | case segmentKindProperyName: 41 | return "~" 42 | } 43 | panic("unknown segment kind") 44 | } 45 | 46 | type innerSegment struct { 47 | kind segmentSubKind 48 | dotName string 49 | selectors []*selector 50 | } 51 | 52 | func (s innerSegment) ToString() string { 53 | builder := strings.Builder{} 54 | switch s.kind { 55 | case segmentDotWildcard: 56 | builder.WriteString("*") 57 | break 58 | case segmentDotMemberName: 59 | builder.WriteString(s.dotName) 60 | break 61 | case segmentLongHand: 62 | builder.WriteString("[") 63 | for i, selector := range s.selectors { 64 | builder.WriteString(selector.ToString()) 65 | if i < len(s.selectors)-1 { 66 | builder.WriteString(", ") 67 | } 68 | } 69 | builder.WriteString("]") 70 | break 71 | default: 72 | panic("unknown child segment kind") 73 | } 74 | return builder.String() 75 | } 76 | 77 | func descend(value *yaml.Node, root *yaml.Node) []*yaml.Node { 78 | result := []*yaml.Node{value} 79 | for _, child := range value.Content { 80 | result = append(result, descend(child, root)...) 81 | } 82 | return result 83 | } 84 | -------------------------------------------------------------------------------- /pkg/jsonpath/selector.go: -------------------------------------------------------------------------------- 1 | package jsonpath 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | ) 8 | 9 | type selectorSubKind int 10 | 11 | const ( 12 | selectorSubKindWildcard selectorSubKind = iota 13 | selectorSubKindName 14 | selectorSubKindArraySlice 15 | selectorSubKindArrayIndex 16 | selectorSubKindFilter 17 | ) 18 | 19 | type slice struct { 20 | start *int64 21 | end *int64 22 | step *int64 23 | } 24 | 25 | type selector struct { 26 | kind selectorSubKind 27 | name string 28 | index int64 29 | slice *slice 30 | filter *filterSelector 31 | } 32 | 33 | func (s selector) ToString() string { 34 | switch s.kind { 35 | case selectorSubKindName: 36 | return "'" + escapeString(s.name) + "'" 37 | case selectorSubKindArrayIndex: 38 | // int to string 39 | return strconv.FormatInt(s.index, 10) 40 | case selectorSubKindFilter: 41 | return "?" + s.filter.ToString() 42 | case selectorSubKindWildcard: 43 | return "*" 44 | case selectorSubKindArraySlice: 45 | builder := strings.Builder{} 46 | if s.slice.start != nil { 47 | builder.WriteString(strconv.FormatInt(*s.slice.start, 10)) 48 | } 49 | builder.WriteString(":") 50 | if s.slice.end != nil { 51 | builder.WriteString(strconv.FormatInt(*s.slice.end, 10)) 52 | } 53 | 54 | if s.slice.step != nil { 55 | builder.WriteString(":") 56 | builder.WriteString(strconv.FormatInt(*s.slice.step, 10)) 57 | } 58 | return builder.String() 59 | default: 60 | panic(fmt.Sprintf("unimplemented selector kind: %v", s.kind)) 61 | } 62 | return "" 63 | } 64 | -------------------------------------------------------------------------------- /pkg/jsonpath/yaml_eval.go: -------------------------------------------------------------------------------- 1 | package jsonpath 2 | 3 | import ( 4 | "fmt" 5 | "gopkg.in/yaml.v3" 6 | "reflect" 7 | "regexp" 8 | "strconv" 9 | "unicode/utf8" 10 | ) 11 | 12 | func (l literal) Equals(value literal) bool { 13 | if l.integer != nil && value.integer != nil { 14 | return *l.integer == *value.integer 15 | } 16 | if l.float64 != nil && value.float64 != nil { 17 | return *l.float64 == *value.float64 18 | } 19 | if l.integer != nil && value.float64 != nil { 20 | return float64(*l.integer) == *value.float64 21 | } 22 | if l.float64 != nil && value.integer != nil { 23 | return *l.float64 == float64(*value.integer) 24 | } 25 | if l.string != nil && value.string != nil { 26 | return *l.string == *value.string 27 | } 28 | if l.bool != nil && value.bool != nil { 29 | return *l.bool == *value.bool 30 | } 31 | if l.null != nil && value.null != nil { 32 | return *l.null == *value.null 33 | } 34 | if l.node != nil && value.node != nil { 35 | return equalsNode(l.node, value.node) 36 | } 37 | if reflect.ValueOf(l).IsZero() && reflect.ValueOf(value).IsZero() { 38 | return true 39 | } 40 | return false 41 | } 42 | 43 | func equalsNode(a *yaml.Node, b *yaml.Node) bool { 44 | // decode into interfaces, then compare 45 | if a.Tag != b.Tag { 46 | return false 47 | } 48 | switch a.Tag { 49 | case "!!str": 50 | return a.Value == b.Value 51 | case "!!int": 52 | return a.Value == b.Value 53 | case "!!float": 54 | return a.Value == b.Value 55 | case "!!bool": 56 | return a.Value == b.Value 57 | case "!!null": 58 | return a.Value == b.Value 59 | case "!!seq": 60 | if len(a.Content) != len(b.Content) { 61 | return false 62 | } 63 | for i := 0; i < len(a.Content); i++ { 64 | if !equalsNode(a.Content[i], b.Content[i]) { 65 | return false 66 | } 67 | } 68 | case "!!map": 69 | if len(a.Content) != len(b.Content) { 70 | return false 71 | } 72 | for i := 0; i < len(a.Content); i += 2 { 73 | if !equalsNode(a.Content[i], b.Content[i]) { 74 | return false 75 | } 76 | if !equalsNode(a.Content[i+1], b.Content[i+1]) { 77 | return false 78 | } 79 | } 80 | } 81 | return true 82 | } 83 | 84 | func (l literal) LessThan(value literal) bool { 85 | if l.integer != nil && value.integer != nil { 86 | return *l.integer < *value.integer 87 | } 88 | if l.float64 != nil && value.float64 != nil { 89 | return *l.float64 < *value.float64 90 | } 91 | if l.integer != nil && value.float64 != nil { 92 | return float64(*l.integer) < *value.float64 93 | } 94 | if l.float64 != nil && value.integer != nil { 95 | return *l.float64 < float64(*value.integer) 96 | } 97 | if l.string != nil && value.string != nil { 98 | return *l.string < *value.string 99 | } 100 | return false 101 | } 102 | 103 | func (l literal) LessThanOrEqual(value literal) bool { 104 | return l.LessThan(value) || l.Equals(value) 105 | } 106 | 107 | func (c comparable) Evaluate(idx index, node *yaml.Node, root *yaml.Node) literal { 108 | if c.literal != nil { 109 | return *c.literal 110 | } 111 | if c.singularQuery != nil { 112 | return c.singularQuery.Evaluate(idx, node, root) 113 | } 114 | if c.functionExpr != nil { 115 | return c.functionExpr.Evaluate(idx, node, root) 116 | } 117 | return literal{} 118 | } 119 | 120 | func (e functionExpr) length(idx index, node *yaml.Node, root *yaml.Node) literal { 121 | args := e.args[0].Eval(idx, node, root) 122 | if args.kind != functionArgTypeLiteral { 123 | return literal{} 124 | } 125 | //* If the argument value is a string, the result is the number of 126 | //Unicode scalar values in the string. 127 | if args.literal != nil && args.literal.string != nil { 128 | res := utf8.RuneCountInString(*args.literal.string) 129 | return literal{integer: &res} 130 | } 131 | //* If the argument value is an array, the result is the number of 132 | //elements in the array. 133 | // 134 | //* If the argument value is an object, the result is the number of 135 | //members in the object. 136 | // 137 | //* For any other argument value, the result is the special result 138 | //Nothing. 139 | 140 | if args.literal.node != nil { 141 | switch args.literal.node.Kind { 142 | case yaml.SequenceNode: 143 | res := len(args.literal.node.Content) 144 | return literal{integer: &res} 145 | case yaml.MappingNode: 146 | res := len(args.literal.node.Content) / 2 147 | return literal{integer: &res} 148 | } 149 | } 150 | return literal{} 151 | } 152 | 153 | func (e functionExpr) count(idx index, node *yaml.Node, root *yaml.Node) literal { 154 | args := e.args[0].Eval(idx, node, root) 155 | if args.kind == functionArgTypeNodes { 156 | res := len(args.nodes) 157 | return literal{integer: &res} 158 | } 159 | 160 | res := 1 161 | return literal{integer: &res} 162 | } 163 | 164 | func (e functionExpr) match(idx index, node *yaml.Node, root *yaml.Node) literal { 165 | arg1 := e.args[0].Eval(idx, node, root) 166 | arg2 := e.args[1].Eval(idx, node, root) 167 | if arg1.kind != functionArgTypeLiteral || arg2.kind != functionArgTypeLiteral { 168 | return literal{} 169 | } 170 | if arg1.literal.string == nil || arg2.literal.string == nil { 171 | return literal{bool: &[]bool{false}[0]} 172 | } 173 | matched, _ := regexp.MatchString(fmt.Sprintf("^(%s)$", *arg2.literal.string), *arg1.literal.string) 174 | return literal{bool: &matched} 175 | } 176 | 177 | func (e functionExpr) search(idx index, node *yaml.Node, root *yaml.Node) literal { 178 | arg1 := e.args[0].Eval(idx, node, root) 179 | arg2 := e.args[1].Eval(idx, node, root) 180 | if arg1.kind != functionArgTypeLiteral || arg2.kind != functionArgTypeLiteral { 181 | return literal{} 182 | } 183 | if arg1.literal.string == nil || arg2.literal.string == nil { 184 | return literal{bool: &[]bool{false}[0]} 185 | } 186 | matched, _ := regexp.MatchString(*arg2.literal.string, *arg1.literal.string) 187 | return literal{bool: &matched} 188 | } 189 | 190 | func (e functionExpr) value(idx index, node *yaml.Node, root *yaml.Node) literal { 191 | // 2.4.8. value() Function Extension 192 | // 193 | //Parameters: 194 | // 1. NodesType 195 | // 196 | //Result: ValueType 197 | //Its only argument is an instance of NodesType (possibly taken from a 198 | //filter-query, as in the example above). The result is an instance of 199 | //ValueType. 200 | // 201 | //* If the argument contains a single node, the result is the value of 202 | //the node. 203 | // 204 | //* If the argument is the empty nodelist or contains multiple nodes, 205 | // the result is Nothing. 206 | 207 | nodesType := e.args[0].Eval(idx, node, root) 208 | if nodesType.kind == functionArgTypeLiteral { 209 | return *nodesType.literal 210 | } else if nodesType.kind == functionArgTypeNodes && len(nodesType.nodes) == 1 { 211 | return *nodesType.nodes[0] 212 | } 213 | return literal{} 214 | } 215 | 216 | func nodeToLiteral(node *yaml.Node) literal { 217 | switch node.Tag { 218 | case "!!str": 219 | return literal{string: &node.Value} 220 | case "!!int": 221 | i, _ := strconv.Atoi(node.Value) 222 | return literal{integer: &i} 223 | case "!!float": 224 | f, _ := strconv.ParseFloat(node.Value, 64) 225 | return literal{float64: &f} 226 | case "!!bool": 227 | b, _ := strconv.ParseBool(node.Value) 228 | return literal{bool: &b} 229 | case "!!null": 230 | b := true 231 | return literal{null: &b} 232 | default: 233 | return literal{node: node} 234 | } 235 | } 236 | 237 | func (e functionExpr) Evaluate(idx index, node *yaml.Node, root *yaml.Node) literal { 238 | switch e.funcType { 239 | case functionTypeLength: 240 | return e.length(idx, node, root) 241 | case functionTypeCount: 242 | return e.count(idx, node, root) 243 | case functionTypeMatch: 244 | return e.match(idx, node, root) 245 | case functionTypeSearch: 246 | return e.search(idx, node, root) 247 | case functionTypeValue: 248 | return e.value(idx, node, root) 249 | } 250 | return literal{} 251 | } 252 | 253 | func (q singularQuery) Evaluate(idx index, node *yaml.Node, root *yaml.Node) literal { 254 | if q.relQuery != nil { 255 | return q.relQuery.Evaluate(idx, node, root) 256 | } 257 | if q.absQuery != nil { 258 | return q.absQuery.Evaluate(idx, node, root) 259 | } 260 | return literal{} 261 | } 262 | 263 | func (q relQuery) Evaluate(idx index, node *yaml.Node, root *yaml.Node) literal { 264 | result := q.Query(idx, node, root) 265 | if len(result) == 1 { 266 | return nodeToLiteral(result[0]) 267 | } 268 | return literal{} 269 | 270 | } 271 | 272 | func (q absQuery) Evaluate(idx index, node *yaml.Node, root *yaml.Node) literal { 273 | result := q.Query(idx, root, root) 274 | if len(result) == 1 { 275 | return nodeToLiteral(result[0]) 276 | } 277 | return literal{} 278 | } 279 | -------------------------------------------------------------------------------- /pkg/jsonpath/yaml_eval_test.go: -------------------------------------------------------------------------------- 1 | package jsonpath 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "gopkg.in/yaml.v3" 8 | ) 9 | 10 | func TestLiteralEquals(t *testing.T) { 11 | testCases := []struct { 12 | name string 13 | literal1 literal 14 | literal2 literal 15 | expected bool 16 | }{ 17 | { 18 | name: "Equal integers", 19 | literal1: literal{integer: intPtr(10)}, 20 | literal2: literal{integer: intPtr(10)}, 21 | expected: true, 22 | }, 23 | { 24 | name: "Different integers", 25 | literal1: literal{integer: intPtr(10)}, 26 | literal2: literal{integer: intPtr(20)}, 27 | expected: false, 28 | }, 29 | { 30 | name: "Equal floats", 31 | literal1: literal{float64: float64Ptr(3.14)}, 32 | literal2: literal{float64: float64Ptr(3.14)}, 33 | expected: true, 34 | }, 35 | { 36 | name: "Different floats", 37 | literal1: literal{float64: float64Ptr(3.14)}, 38 | literal2: literal{float64: float64Ptr(2.71)}, 39 | expected: false, 40 | }, 41 | { 42 | name: "Equal strings", 43 | literal1: literal{string: stringPtr("hello")}, 44 | literal2: literal{string: stringPtr("hello")}, 45 | expected: true, 46 | }, 47 | { 48 | name: "Different strings", 49 | literal1: literal{string: stringPtr("hello")}, 50 | literal2: literal{string: stringPtr("world")}, 51 | expected: false, 52 | }, 53 | { 54 | name: "Equal bools", 55 | literal1: literal{bool: boolPtr(true)}, 56 | literal2: literal{bool: boolPtr(true)}, 57 | expected: true, 58 | }, 59 | { 60 | name: "Different bools", 61 | literal1: literal{bool: boolPtr(true)}, 62 | literal2: literal{bool: boolPtr(false)}, 63 | expected: false, 64 | }, 65 | { 66 | name: "Equal nulls", 67 | literal1: literal{null: boolPtr(true)}, 68 | literal2: literal{null: boolPtr(true)}, 69 | expected: true, 70 | }, 71 | { 72 | name: "Different nulls", 73 | literal1: literal{null: boolPtr(true)}, 74 | literal2: literal{null: boolPtr(false)}, 75 | expected: false, 76 | }, 77 | { 78 | name: "Different types", 79 | literal1: literal{integer: intPtr(10)}, 80 | literal2: literal{string: stringPtr("10")}, 81 | expected: false, 82 | }, 83 | } 84 | 85 | for _, tc := range testCases { 86 | t.Run(tc.name, func(t *testing.T) { 87 | result := tc.literal1.Equals(tc.literal2) 88 | if result != tc.expected { 89 | t.Errorf("Expected %v, but got %v", tc.expected, result) 90 | } 91 | }) 92 | } 93 | } 94 | 95 | func TestLiteralLessThan(t *testing.T) { 96 | testCases := []struct { 97 | name string 98 | literal1 literal 99 | literal2 literal 100 | expected bool 101 | }{ 102 | { 103 | name: "integer less than", 104 | literal1: literal{integer: intPtr(10)}, 105 | literal2: literal{integer: intPtr(20)}, 106 | expected: true, 107 | }, 108 | { 109 | name: "integer not less than", 110 | literal1: literal{integer: intPtr(20)}, 111 | literal2: literal{integer: intPtr(10)}, 112 | expected: false, 113 | }, 114 | { 115 | name: "Float less than", 116 | literal1: literal{float64: float64Ptr(3.14)}, 117 | literal2: literal{float64: float64Ptr(6.28)}, 118 | expected: true, 119 | }, 120 | { 121 | name: "Float not less than", 122 | literal1: literal{float64: float64Ptr(6.28)}, 123 | literal2: literal{float64: float64Ptr(3.14)}, 124 | expected: false, 125 | }, 126 | { 127 | name: "Different types", 128 | literal1: literal{integer: intPtr(10)}, 129 | literal2: literal{string: stringPtr("10")}, 130 | expected: false, 131 | }, 132 | } 133 | 134 | for _, tc := range testCases { 135 | t.Run(tc.name, func(t *testing.T) { 136 | result := tc.literal1.LessThan(tc.literal2) 137 | if result != tc.expected { 138 | t.Errorf("Expected %v, but got %v", tc.expected, result) 139 | } 140 | }) 141 | } 142 | } 143 | 144 | func TestLiteralLessThanOrEqual(t *testing.T) { 145 | testCases := []struct { 146 | name string 147 | literal1 literal 148 | literal2 literal 149 | expected bool 150 | }{ 151 | { 152 | name: "integer less than or equal", 153 | literal1: literal{integer: intPtr(10)}, 154 | literal2: literal{integer: intPtr(20)}, 155 | expected: true, 156 | }, 157 | { 158 | name: "integer equal", 159 | literal1: literal{integer: intPtr(10)}, 160 | literal2: literal{integer: intPtr(10)}, 161 | expected: true, 162 | }, 163 | { 164 | name: "integer not less than or equal", 165 | literal1: literal{integer: intPtr(20)}, 166 | literal2: literal{integer: intPtr(10)}, 167 | expected: false, 168 | }, 169 | { 170 | name: "Float less than or equal", 171 | literal1: literal{float64: float64Ptr(3.14)}, 172 | literal2: literal{float64: float64Ptr(6.28)}, 173 | expected: true, 174 | }, 175 | { 176 | name: "Float equal", 177 | literal1: literal{float64: float64Ptr(3.14)}, 178 | literal2: literal{float64: float64Ptr(3.14)}, 179 | expected: true, 180 | }, 181 | { 182 | name: "Float not less than or equal", 183 | literal1: literal{float64: float64Ptr(6.28)}, 184 | literal2: literal{float64: float64Ptr(3.14)}, 185 | expected: false, 186 | }, 187 | { 188 | name: "Different types", 189 | literal1: literal{integer: intPtr(10)}, 190 | literal2: literal{string: stringPtr("10")}, 191 | expected: false, 192 | }, 193 | } 194 | 195 | for _, tc := range testCases { 196 | t.Run(tc.name, func(t *testing.T) { 197 | result := tc.literal1.LessThanOrEqual(tc.literal2) 198 | if result != tc.expected { 199 | t.Errorf("Expected %v, but got %v", tc.expected, result) 200 | } 201 | }) 202 | } 203 | } 204 | 205 | func TestComparableEvaluate(t *testing.T) { 206 | testCases := []struct { 207 | name string 208 | comparable comparable 209 | node *yaml.Node 210 | root *yaml.Node 211 | expected literal 212 | }{ 213 | { 214 | name: "literal", 215 | comparable: comparable{literal: &literal{integer: intPtr(10)}}, 216 | node: yamlNodeFromString("foo"), 217 | root: yamlNodeFromString("foo"), 218 | expected: literal{integer: intPtr(10)}, 219 | }, 220 | { 221 | name: "singularQuery", 222 | comparable: comparable{singularQuery: &singularQuery{absQuery: &absQuery{segments: []*segment{}}}}, 223 | node: yamlNodeFromString("10"), 224 | root: yamlNodeFromString("10"), 225 | expected: literal{integer: intPtr(10)}, 226 | }, 227 | { 228 | name: "functionExpr", 229 | comparable: comparable{functionExpr: &functionExpr{funcType: functionTypeLength, args: []*functionArgument{{filterQuery: &filterQuery{relQuery: &relQuery{segments: []*segment{}}}}}}}, 230 | node: yamlNodeFromString(`["a", "b", "c"]`), 231 | root: yamlNodeFromString(`["a", "b", "c"]`), 232 | expected: literal{integer: intPtr(3)}, 233 | }, 234 | } 235 | 236 | for _, tc := range testCases { 237 | t.Run(tc.name, func(t *testing.T) { 238 | result := tc.comparable.Evaluate(&_index{}, tc.node, tc.root) 239 | if !reflect.DeepEqual(result, tc.expected) { 240 | t.Errorf("Expected %v, but got %v", tc.expected, result) 241 | } 242 | }) 243 | } 244 | } 245 | 246 | func TestSingularQueryEvaluate(t *testing.T) { 247 | testCases := []struct { 248 | name string 249 | query singularQuery 250 | node *yaml.Node 251 | root *yaml.Node 252 | expected literal 253 | }{ 254 | { 255 | name: "relQuery", 256 | query: singularQuery{relQuery: &relQuery{segments: []*segment{}}}, 257 | node: yamlNodeFromString("10"), 258 | root: yamlNodeFromString("10"), 259 | expected: literal{integer: intPtr(10)}, 260 | }, 261 | { 262 | name: "absQuery", 263 | query: singularQuery{absQuery: &absQuery{segments: []*segment{}}}, 264 | node: yamlNodeFromString("10"), 265 | root: yamlNodeFromString("10"), 266 | expected: literal{integer: intPtr(10)}, 267 | }, 268 | } 269 | 270 | for _, tc := range testCases { 271 | t.Run(tc.name, func(t *testing.T) { 272 | result := tc.query.Evaluate(&_index{}, tc.node, tc.root) 273 | if !reflect.DeepEqual(result, tc.expected) { 274 | t.Errorf("Expected %v, but got %v", tc.expected, result) 275 | } 276 | }) 277 | } 278 | } 279 | 280 | func TestRelQueryEvaluate(t *testing.T) { 281 | testCases := []struct { 282 | name string 283 | query relQuery 284 | node *yaml.Node 285 | root *yaml.Node 286 | expected literal 287 | }{ 288 | { 289 | name: "Single node", 290 | query: relQuery{segments: []*segment{}}, 291 | node: yamlNodeFromString("10"), 292 | root: yamlNodeFromString("10"), 293 | expected: literal{integer: intPtr(10)}, 294 | }, 295 | { 296 | name: "child segment", 297 | query: relQuery{segments: []*segment{{kind: segmentKindChild, child: &innerSegment{kind: segmentDotMemberName, dotName: "foo"}}}}, 298 | node: yamlNodeFromString(`{"foo": "bar"}`), 299 | root: yamlNodeFromString(`{"foo": "bar"}`), 300 | expected: literal{string: stringPtr("bar")}, 301 | }, 302 | } 303 | 304 | for _, tc := range testCases { 305 | t.Run(tc.name, func(t *testing.T) { 306 | result := tc.query.Evaluate(&_index{}, tc.node, tc.root) 307 | if !reflect.DeepEqual(result, tc.expected) { 308 | t.Errorf("Expected %v, but got %v", tc.expected, result) 309 | } 310 | }) 311 | } 312 | } 313 | 314 | func TestAbsQueryEvaluate(t *testing.T) { 315 | testCases := []struct { 316 | name string 317 | query absQuery 318 | node *yaml.Node 319 | root *yaml.Node 320 | expected literal 321 | }{ 322 | { 323 | name: "Root node", 324 | query: absQuery{segments: []*segment{}}, 325 | node: yamlNodeFromString("10"), 326 | root: yamlNodeFromString("10"), 327 | expected: literal{integer: intPtr(10)}, 328 | }, 329 | { 330 | name: "child segment", 331 | query: absQuery{segments: []*segment{{kind: segmentKindChild, child: &innerSegment{kind: segmentDotMemberName, dotName: "foo"}}}}, 332 | node: yamlNodeFromString(`{"foo": "bar"}`), 333 | root: yamlNodeFromString(`{"foo": "bar"}`), 334 | expected: literal{string: stringPtr("bar")}, 335 | }, 336 | } 337 | 338 | for _, tc := range testCases { 339 | t.Run(tc.name, func(t *testing.T) { 340 | result := tc.query.Evaluate(&_index{}, tc.node, tc.root) 341 | if !reflect.DeepEqual(result, tc.expected) { 342 | t.Errorf("Expected %v, but got %v", tc.expected, result) 343 | } 344 | }) 345 | } 346 | } 347 | 348 | func intPtr(i int) *int { 349 | return &i 350 | } 351 | 352 | func float64Ptr(f float64) *float64 { 353 | return &f 354 | } 355 | 356 | func stringPtr(s string) *string { 357 | return &s 358 | } 359 | 360 | func boolPtr(b bool) *bool { 361 | return &b 362 | } 363 | 364 | func yamlNodeFromString(s string) *yaml.Node { 365 | var node yaml.Node 366 | err := yaml.Unmarshal([]byte(s), &node) 367 | if err != nil { 368 | panic(err) 369 | } 370 | if len(node.Content) != 1 { 371 | panic("expected single node") 372 | } 373 | return node.Content[0] 374 | } 375 | -------------------------------------------------------------------------------- /pkg/jsonpath/yaml_query.go: -------------------------------------------------------------------------------- 1 | package jsonpath 2 | 3 | import ( 4 | "gopkg.in/yaml.v3" 5 | ) 6 | 7 | type Evaluator interface { 8 | Query(current *yaml.Node, root *yaml.Node) []*yaml.Node 9 | } 10 | 11 | type index interface { 12 | setPropertyKey(key *yaml.Node, value *yaml.Node) 13 | getPropertyKey(key *yaml.Node) *yaml.Node 14 | } 15 | 16 | type _index struct { 17 | propertyKeys map[*yaml.Node]*yaml.Node 18 | } 19 | 20 | func (i *_index) setPropertyKey(key *yaml.Node, value *yaml.Node) { 21 | if i != nil && i.propertyKeys != nil { 22 | i.propertyKeys[key] = value 23 | } 24 | } 25 | 26 | func (i *_index) getPropertyKey(key *yaml.Node) *yaml.Node { 27 | if i != nil { 28 | return i.propertyKeys[key] 29 | } 30 | return nil 31 | } 32 | 33 | // jsonPathAST can be Evaluated 34 | var _ Evaluator = jsonPathAST{} 35 | 36 | func (q jsonPathAST) Query(current *yaml.Node, root *yaml.Node) []*yaml.Node { 37 | idx := _index{ 38 | propertyKeys: map[*yaml.Node]*yaml.Node{}, 39 | } 40 | result := make([]*yaml.Node, 0) 41 | // If the top level node is a documentnode, unwrap it 42 | if root.Kind == yaml.DocumentNode && len(root.Content) == 1 { 43 | root = root.Content[0] 44 | } 45 | result = append(result, root) 46 | 47 | for _, segment := range q.segments { 48 | newValue := []*yaml.Node{} 49 | for _, value := range result { 50 | newValue = append(newValue, segment.Query(&idx, value, root)...) 51 | } 52 | result = newValue 53 | } 54 | return result 55 | } 56 | 57 | func (s segment) Query(idx index, value *yaml.Node, root *yaml.Node) []*yaml.Node { 58 | switch s.kind { 59 | case segmentKindChild: 60 | return s.child.Query(idx, value, root) 61 | case segmentKindDescendant: 62 | // run the inner segment against this node 63 | var result = []*yaml.Node{} 64 | children := descend(value, root) 65 | for _, child := range children { 66 | result = append(result, s.descendant.Query(idx, child, root)...) 67 | } 68 | // make children unique by pointer value 69 | result = unique(result) 70 | return result 71 | case segmentKindProperyName: 72 | found := idx.getPropertyKey(value) 73 | if found != nil { 74 | return []*yaml.Node{found} 75 | } 76 | return []*yaml.Node{} 77 | } 78 | panic("no segment type") 79 | } 80 | 81 | func unique(nodes []*yaml.Node) []*yaml.Node { 82 | // stably returns a new slice containing only the unique elements from nodes 83 | res := make([]*yaml.Node, 0) 84 | seen := make(map[*yaml.Node]bool) 85 | for _, node := range nodes { 86 | if _, ok := seen[node]; !ok { 87 | res = append(res, node) 88 | seen[node] = true 89 | } 90 | } 91 | return res 92 | } 93 | 94 | func (s innerSegment) Query(idx index, value *yaml.Node, root *yaml.Node) []*yaml.Node { 95 | result := []*yaml.Node{} 96 | 97 | switch s.kind { 98 | case segmentDotWildcard: 99 | // Handle wildcard - get all children 100 | switch value.Kind { 101 | case yaml.MappingNode: 102 | // in a mapping node, keys and values alternate 103 | // we just want to return the values 104 | for i, child := range value.Content { 105 | if i%2 == 1 { 106 | idx.setPropertyKey(value.Content[i-1], value) 107 | idx.setPropertyKey(child, value.Content[i-1]) 108 | result = append(result, child) 109 | } 110 | } 111 | case yaml.SequenceNode: 112 | for _, child := range value.Content { 113 | result = append(result, child) 114 | } 115 | } 116 | return result 117 | case segmentDotMemberName: 118 | // Handle member access 119 | if value.Kind == yaml.MappingNode { 120 | // In YAML mapping nodes, keys and values alternate 121 | 122 | for i := 0; i < len(value.Content); i += 2 { 123 | key := value.Content[i] 124 | val := value.Content[i+1] 125 | 126 | if key.Value == s.dotName { 127 | idx.setPropertyKey(key, value) 128 | idx.setPropertyKey(val, key) 129 | result = append(result, val) 130 | break 131 | } 132 | } 133 | } 134 | 135 | case segmentLongHand: 136 | // Handle long hand selectors 137 | for _, selector := range s.selectors { 138 | result = append(result, selector.Query(idx, value, root)...) 139 | } 140 | default: 141 | panic("unknown child segment kind") 142 | } 143 | 144 | return result 145 | 146 | } 147 | 148 | func (s selector) Query(idx index, value *yaml.Node, root *yaml.Node) []*yaml.Node { 149 | switch s.kind { 150 | case selectorSubKindName: 151 | if value.Kind != yaml.MappingNode { 152 | return nil 153 | } 154 | // MappingNode children is a list of alternating keys and values 155 | var key string 156 | for i, child := range value.Content { 157 | if i%2 == 0 { 158 | key = child.Value 159 | continue 160 | } 161 | if key == s.name && i%2 == 1 { 162 | idx.setPropertyKey(value.Content[i], value.Content[i-1]) 163 | idx.setPropertyKey(value.Content[i-1], value) 164 | return []*yaml.Node{child} 165 | } 166 | } 167 | case selectorSubKindArrayIndex: 168 | if value.Kind != yaml.SequenceNode { 169 | return nil 170 | } 171 | // if out of bounds, return nothing 172 | if s.index >= int64(len(value.Content)) || s.index < -int64(len(value.Content)) { 173 | return nil 174 | } 175 | // if index is negative, go backwards 176 | if s.index < 0 { 177 | return []*yaml.Node{value.Content[int64(len(value.Content))+s.index]} 178 | } 179 | return []*yaml.Node{value.Content[s.index]} 180 | case selectorSubKindWildcard: 181 | if value.Kind == yaml.SequenceNode { 182 | return value.Content 183 | } else if value.Kind == yaml.MappingNode { 184 | var result []*yaml.Node 185 | for i, child := range value.Content { 186 | if i%2 == 1 { 187 | idx.setPropertyKey(value.Content[i-1], value) 188 | idx.setPropertyKey(child, value.Content[i-1]) 189 | result = append(result, child) 190 | } 191 | } 192 | return result 193 | } 194 | return nil 195 | case selectorSubKindArraySlice: 196 | if value.Kind != yaml.SequenceNode { 197 | return nil 198 | } 199 | if len(value.Content) == 0 { 200 | return nil 201 | } 202 | step := int64(1) 203 | if s.slice.step != nil { 204 | step = *s.slice.step 205 | } 206 | if step == 0 { 207 | return nil 208 | } 209 | 210 | start, end := s.slice.start, s.slice.end 211 | lower, upper := bounds(start, end, step, int64(len(value.Content))) 212 | 213 | var result []*yaml.Node 214 | if step > 0 { 215 | for i := lower; i < upper; i += step { 216 | result = append(result, value.Content[i]) 217 | } 218 | } else { 219 | for i := upper; i > lower; i += step { 220 | result = append(result, value.Content[i]) 221 | } 222 | } 223 | 224 | return result 225 | case selectorSubKindFilter: 226 | var result []*yaml.Node 227 | switch value.Kind { 228 | case yaml.MappingNode: 229 | for i := 1; i < len(value.Content); i += 2 { 230 | idx.setPropertyKey(value.Content[i-1], value) 231 | idx.setPropertyKey(value.Content[i], value.Content[i-1]) 232 | if s.filter.Matches(idx, value.Content[i], root) { 233 | result = append(result, value.Content[i]) 234 | } 235 | } 236 | case yaml.SequenceNode: 237 | for _, child := range value.Content { 238 | if s.filter.Matches(idx, child, root) { 239 | result = append(result, child) 240 | } 241 | } 242 | } 243 | return result 244 | } 245 | return nil 246 | } 247 | 248 | func normalize(i, length int64) int64 { 249 | if i >= 0 { 250 | return i 251 | } 252 | return length + i 253 | } 254 | 255 | func bounds(start, end *int64, step, length int64) (int64, int64) { 256 | var nStart, nEnd int64 257 | if start != nil { 258 | nStart = normalize(*start, length) 259 | } else if step > 0 { 260 | nStart = 0 261 | } else { 262 | nStart = length - 1 263 | } 264 | if end != nil { 265 | nEnd = normalize(*end, length) 266 | } else if step > 0 { 267 | nEnd = length 268 | } else { 269 | nEnd = -1 270 | } 271 | 272 | var lower, upper int64 273 | if step >= 0 { 274 | lower = max(min(nStart, length), 0) 275 | upper = min(max(nEnd, 0), length) 276 | } else { 277 | upper = min(max(nStart, -1), length-1) 278 | lower = min(max(nEnd, -1), length-1) 279 | } 280 | 281 | return lower, upper 282 | } 283 | 284 | func (s filterSelector) Matches(idx index, node *yaml.Node, root *yaml.Node) bool { 285 | return s.expression.Matches(idx, node, root) 286 | } 287 | 288 | func (e logicalOrExpr) Matches(idx index, node *yaml.Node, root *yaml.Node) bool { 289 | for _, expr := range e.expressions { 290 | if expr.Matches(idx, node, root) { 291 | return true 292 | } 293 | } 294 | return false 295 | } 296 | 297 | func (e logicalAndExpr) Matches(idx index, node *yaml.Node, root *yaml.Node) bool { 298 | for _, expr := range e.expressions { 299 | if !expr.Matches(idx, node, root) { 300 | return false 301 | } 302 | } 303 | return true 304 | } 305 | 306 | func (e basicExpr) Matches(idx index, node *yaml.Node, root *yaml.Node) bool { 307 | if e.parenExpr != nil { 308 | result := e.parenExpr.expr.Matches(idx, node, root) 309 | if e.parenExpr.not { 310 | return !result 311 | } 312 | return result 313 | } else if e.comparisonExpr != nil { 314 | return e.comparisonExpr.Matches(idx, node, root) 315 | } else if e.testExpr != nil { 316 | return e.testExpr.Matches(idx, node, root) 317 | } 318 | return false 319 | } 320 | 321 | func (e comparisonExpr) Matches(idx index, node *yaml.Node, root *yaml.Node) bool { 322 | leftValue := e.left.Evaluate(idx, node, root) 323 | rightValue := e.right.Evaluate(idx, node, root) 324 | 325 | switch e.op { 326 | case equalTo: 327 | return leftValue.Equals(rightValue) 328 | case notEqualTo: 329 | return !leftValue.Equals(rightValue) 330 | case lessThan: 331 | return leftValue.LessThan(rightValue) 332 | case lessThanEqualTo: 333 | return leftValue.LessThanOrEqual(rightValue) 334 | case greaterThan: 335 | return rightValue.LessThan(leftValue) 336 | case greaterThanEqualTo: 337 | return rightValue.LessThanOrEqual(leftValue) 338 | default: 339 | return false 340 | } 341 | } 342 | 343 | func (e testExpr) Matches(idx index, node *yaml.Node, root *yaml.Node) bool { 344 | var result bool 345 | if e.filterQuery != nil { 346 | result = len(e.filterQuery.Query(idx, node, root)) > 0 347 | } else if e.functionExpr != nil { 348 | funcResult := e.functionExpr.Evaluate(idx, node, root) 349 | if funcResult.bool != nil { 350 | result = *funcResult.bool 351 | } else if funcResult.null == nil { 352 | result = true 353 | } 354 | } 355 | if e.not { 356 | return !result 357 | } 358 | return result 359 | } 360 | 361 | func (q filterQuery) Query(idx index, node *yaml.Node, root *yaml.Node) []*yaml.Node { 362 | if q.relQuery != nil { 363 | return q.relQuery.Query(idx, node, root) 364 | } 365 | if q.jsonPathQuery != nil { 366 | return q.jsonPathQuery.Query(node, root) 367 | } 368 | return nil 369 | } 370 | 371 | func (q relQuery) Query(idx index, node *yaml.Node, root *yaml.Node) []*yaml.Node { 372 | result := []*yaml.Node{node} 373 | for _, seg := range q.segments { 374 | var newResult []*yaml.Node 375 | for _, value := range result { 376 | newResult = append(newResult, seg.Query(idx, value, root)...) 377 | } 378 | result = newResult 379 | } 380 | return result 381 | } 382 | 383 | func (q absQuery) Query(idx index, node *yaml.Node, root *yaml.Node) []*yaml.Node { 384 | result := []*yaml.Node{root} 385 | for _, seg := range q.segments { 386 | var newResult []*yaml.Node 387 | for _, value := range result { 388 | newResult = append(newResult, seg.Query(idx, value, root)...) 389 | } 390 | result = newResult 391 | } 392 | return result 393 | } 394 | -------------------------------------------------------------------------------- /pkg/jsonpath/yaml_query_test.go: -------------------------------------------------------------------------------- 1 | package jsonpath 2 | 3 | import ( 4 | "github.com/speakeasy-api/jsonpath/pkg/jsonpath/config" 5 | "github.com/speakeasy-api/jsonpath/pkg/jsonpath/token" 6 | "gopkg.in/yaml.v3" 7 | "reflect" 8 | "strings" 9 | "testing" 10 | ) 11 | 12 | func TestQuery(t *testing.T) { 13 | tests := []struct { 14 | name string 15 | input string 16 | yaml string 17 | expected []string 18 | }{ 19 | { 20 | name: "Root node", 21 | input: "$", 22 | yaml: "foo", 23 | expected: []string{"foo"}, 24 | }, 25 | { 26 | name: "Single child", 27 | input: "$.store", 28 | yaml: ` 29 | store: 30 | book: 31 | - title: Book 1 32 | - title: Book 2 33 | `, 34 | expected: []string{ 35 | "book:\n - title: Book 1\n - title: Book 2", 36 | }, 37 | }, 38 | { 39 | name: "Multiple children", 40 | input: "$.store.book[*].title", 41 | yaml: ` 42 | store: 43 | book: 44 | - title: Book 1 45 | - title: Book 2 46 | `, 47 | expected: []string{"Book 1", "Book 2"}, 48 | }, 49 | { 50 | name: "Array index", 51 | input: "$[1]", 52 | yaml: "[foo, bar, baz]", 53 | expected: []string{"bar"}, 54 | }, 55 | { 56 | name: "Array slice", 57 | input: "$[1:3]", 58 | yaml: "[foo, bar, baz, qux]", 59 | expected: []string{"bar", "baz"}, 60 | }, 61 | { 62 | name: "Array slice with step", 63 | input: "$[0:5:2]", 64 | yaml: "[foo, bar, baz, qux, quux]", 65 | expected: []string{"foo", "baz", "quux"}, 66 | }, 67 | { 68 | name: "Filter expression", 69 | input: "$.store.book[?(@.price < 10)].title", 70 | yaml: ` 71 | store: 72 | book: 73 | - title: Book 1 74 | price: 9.99 75 | - title: Book 2 76 | price: 12.99 77 | `, 78 | expected: []string{"Book 1"}, 79 | }, 80 | { 81 | name: "Nested filter expression", 82 | input: "$.store.book[?(@.price < 10 && @.category == 'fiction')].title", 83 | yaml: ` 84 | store: 85 | book: 86 | - title: Book 1 87 | price: 9.99 88 | category: fiction 89 | - title: Book 2 90 | price: 8.99 91 | category: non-fiction 92 | `, 93 | expected: []string{"Book 1"}, 94 | }, 95 | } 96 | 97 | for _, test := range tests { 98 | t.Run(test.name, func(t *testing.T) { 99 | var root yaml.Node 100 | err := yaml.Unmarshal([]byte(test.yaml), &root) 101 | if err != nil { 102 | t.Errorf("Error parsing YAML: %v", err) 103 | return 104 | } 105 | 106 | tokenizer := token.NewTokenizer(test.input) 107 | parser := newParserPrivate(tokenizer, tokenizer.Tokenize()) 108 | err = parser.parse() 109 | if err != nil { 110 | t.Errorf("Error parsing JSON Path: %v", err) 111 | return 112 | } 113 | 114 | result := parser.ast.Query(&root, &root) 115 | var actual []string 116 | for _, node := range result { 117 | actual = append(actual, nodeToString(node)) 118 | } 119 | 120 | if !reflect.DeepEqual(actual, test.expected) { 121 | t.Errorf("Expected:\n%v\nGot:\n%v", test.expected, actual) 122 | } 123 | }) 124 | } 125 | } 126 | 127 | func nodeToString(node *yaml.Node) string { 128 | var builder strings.Builder 129 | err := yaml.NewEncoder(&builder).Encode(node) 130 | if err != nil { 131 | panic(err) 132 | } 133 | return strings.TrimSpace(builder.String()) 134 | } 135 | 136 | func TestPropertyNameQuery(t *testing.T) { 137 | tests := []struct { 138 | name string 139 | input string 140 | yaml string 141 | expected []string 142 | }{ 143 | { 144 | name: "Simple property name", 145 | input: "$.store~", 146 | yaml: ` 147 | store: book-store 148 | `, 149 | expected: []string{"store"}, 150 | }, 151 | { 152 | name: "Property name in filter", 153 | input: "$.items[?(@~ == 'a')]", 154 | yaml: ` 155 | items: 156 | a: item1 157 | b: item2 158 | `, 159 | expected: []string{"item1"}, 160 | }, 161 | { 162 | name: "Chained property names", 163 | input: "$.store.items[*].type~", 164 | yaml: ` 165 | store: 166 | items: 167 | - type: book 168 | name: Book 1 169 | - type: magazine 170 | name: Magazine 1 171 | `, 172 | expected: []string{"type", "type"}, 173 | }, 174 | { 175 | name: "Property name in a function", 176 | input: "$.store.items[?(length(@~) == 2)].found", 177 | yaml: ` 178 | store: 179 | items: 180 | ab: { found: true } 181 | cdef: { found: false } 182 | `, 183 | expected: []string{"true"}, 184 | }, 185 | { 186 | name: "Property name in a function inverse case", 187 | input: "$.store.items[?(length(@~) != 2)].found", 188 | yaml: ` 189 | store: 190 | items: 191 | ab: { found: true } 192 | cdef: { found: false } 193 | `, 194 | expected: []string{"false"}, 195 | }, 196 | { 197 | name: "Property name on nested objects", 198 | input: "$.deeply.nested.object~", 199 | yaml: ` 200 | deeply: 201 | nested: 202 | object: 203 | key1: value1 204 | key2: value2 205 | `, 206 | expected: []string{"object"}, 207 | }, 208 | { 209 | name: "All property names on a nested object", 210 | input: "$.deeply.nested.object[*]~", 211 | yaml: ` 212 | deeply: 213 | nested: 214 | object: 215 | key1: value1 216 | key2: value2 217 | `, 218 | expected: []string{"key1", "key2"}, 219 | }, 220 | { 221 | name: "All property names on multiple nested objects", 222 | input: "$.deeply.[*].object[*]~", 223 | yaml: ` 224 | deeply: 225 | nested: 226 | object: 227 | key1: value1 228 | key2: value2 229 | nested2: 230 | object: 231 | key3: value3 232 | key4: value4 233 | `, 234 | expected: []string{"key1", "key2", "key3", "key4"}, 235 | }, 236 | { 237 | name: "Custom x-my-ignore extension filter", 238 | input: "$.paths[?@[\"x-my-ignore\"][?@ == \"match\"]].found", 239 | yaml: ` 240 | openapi: 3.1.0 241 | info: 242 | title: Test 243 | version: 0.1.0 244 | summary: Test Summary 245 | description: |- 246 | Some test description. 247 | About our test document. 248 | paths: 249 | /anything/ignored: 250 | x-my-ignore: [match, not_matched] 251 | found: true 252 | /anything/not-ignored: 253 | x-my-ignore: [not_matched] 254 | found: false 255 | `, 256 | expected: []string{"true"}, 257 | }, 258 | } 259 | 260 | for _, test := range tests { 261 | t.Run(test.name, func(t *testing.T) { 262 | var root yaml.Node 263 | err := yaml.Unmarshal([]byte(test.yaml), &root) 264 | if err != nil { 265 | t.Errorf("Error parsing YAML: %v", err) 266 | return 267 | } 268 | 269 | tokenizer := token.NewTokenizer(test.input, config.WithPropertyNameExtension()) 270 | parser := newParserPrivate(tokenizer, tokenizer.Tokenize(), config.WithPropertyNameExtension()) 271 | err = parser.parse() 272 | if err != nil { 273 | t.Errorf("Error parsing JSON Path: %v", err) 274 | return 275 | } 276 | 277 | result := parser.ast.Query(&root, &root) 278 | var actual []string 279 | for _, node := range result { 280 | actual = append(actual, nodeToString(node)) 281 | } 282 | 283 | if !reflect.DeepEqual(actual, test.expected) { 284 | t.Errorf("Expected:\n%v\nGot:\n%v", test.expected, actual) 285 | } 286 | }) 287 | } 288 | } 289 | -------------------------------------------------------------------------------- /pkg/overlay/apply.go: -------------------------------------------------------------------------------- 1 | package overlay 2 | 3 | import ( 4 | "github.com/speakeasy-api/jsonpath/pkg/jsonpath" 5 | "github.com/speakeasy-api/jsonpath/pkg/jsonpath/config" 6 | "gopkg.in/yaml.v3" 7 | ) 8 | 9 | // ApplyTo will take an overlay and apply its changes to the given YAML 10 | // document. 11 | func (o *Overlay) ApplyTo(root *yaml.Node) error { 12 | for _, action := range o.Actions { 13 | var err error 14 | if action.Remove { 15 | err = applyRemoveAction(root, action) 16 | } else { 17 | err = applyUpdateAction(root, action) 18 | } 19 | 20 | if err != nil { 21 | return err 22 | } 23 | } 24 | 25 | return nil 26 | } 27 | 28 | func applyRemoveAction(root *yaml.Node, action Action) error { 29 | if action.Target == "" { 30 | return nil 31 | } 32 | 33 | idx := newParentIndex(root) 34 | 35 | p, err := jsonpath.NewPath(action.Target, config.WithPropertyNameExtension()) 36 | if err != nil { 37 | return err 38 | } 39 | 40 | nodes := p.Query(root) 41 | if err != nil { 42 | return err 43 | } 44 | 45 | for _, node := range nodes { 46 | removeNode(idx, node) 47 | } 48 | 49 | return nil 50 | } 51 | 52 | func removeNode(idx parentIndex, node *yaml.Node) { 53 | parent := idx.getParent(node) 54 | if parent == nil { 55 | return 56 | } 57 | 58 | for i, child := range parent.Content { 59 | if child == node { 60 | switch parent.Kind { 61 | case yaml.MappingNode: 62 | if i%2 == 1 { 63 | // if we select a value, we should delete the key too 64 | parent.Content = append(parent.Content[:i-1], parent.Content[i+1:]...) 65 | } else { 66 | // if we select a key, we should delete the value 67 | parent.Content = append(parent.Content[:i], parent.Content[i+2:]...) 68 | } 69 | return 70 | case yaml.SequenceNode: 71 | parent.Content = append(parent.Content[:i], parent.Content[i+1:]...) 72 | return 73 | } 74 | } 75 | } 76 | } 77 | 78 | func applyUpdateAction(root *yaml.Node, action Action) error { 79 | if action.Target == "" { 80 | return nil 81 | } 82 | 83 | if action.Update.IsZero() { 84 | return nil 85 | } 86 | 87 | p, err := jsonpath.NewPath(action.Target, config.WithPropertyNameExtension()) 88 | if err != nil { 89 | return err 90 | } 91 | 92 | nodes := p.Query(root) 93 | 94 | for _, node := range nodes { 95 | if err := updateNode(node, &action.Update); err != nil { 96 | return err 97 | } 98 | } 99 | 100 | return nil 101 | } 102 | 103 | func updateNode(node *yaml.Node, updateNode *yaml.Node) error { 104 | mergeNode(node, updateNode) 105 | return nil 106 | } 107 | 108 | func mergeNode(node *yaml.Node, merge *yaml.Node) { 109 | if node.Kind != merge.Kind { 110 | *node = *clone(merge) 111 | return 112 | } 113 | switch node.Kind { 114 | default: 115 | node.Value = merge.Value 116 | case yaml.MappingNode: 117 | mergeMappingNode(node, merge) 118 | case yaml.SequenceNode: 119 | mergeSequenceNode(node, merge) 120 | } 121 | } 122 | 123 | // mergeMappingNode will perform a shallow merge of the merge node into the main 124 | // node. 125 | func mergeMappingNode(node *yaml.Node, merge *yaml.Node) { 126 | NextKey: 127 | for i := 0; i < len(merge.Content); i += 2 { 128 | mergeKey := merge.Content[i].Value 129 | mergeValue := merge.Content[i+1] 130 | 131 | for j := 0; j < len(node.Content); j += 2 { 132 | nodeKey := node.Content[j].Value 133 | if nodeKey == mergeKey { 134 | mergeNode(node.Content[j+1], mergeValue) 135 | continue NextKey 136 | } 137 | } 138 | 139 | node.Content = append(node.Content, merge.Content[i], clone(mergeValue)) 140 | } 141 | } 142 | 143 | // mergeSequenceNode will append the merge node's content to the original node. 144 | func mergeSequenceNode(node *yaml.Node, merge *yaml.Node) { 145 | node.Content = append(node.Content, clone(merge).Content...) 146 | } 147 | 148 | func clone(node *yaml.Node) *yaml.Node { 149 | newNode := &yaml.Node{ 150 | Kind: node.Kind, 151 | Style: node.Style, 152 | Tag: node.Tag, 153 | Value: node.Value, 154 | Anchor: node.Anchor, 155 | HeadComment: node.HeadComment, 156 | LineComment: node.LineComment, 157 | FootComment: node.FootComment, 158 | } 159 | if node.Alias != nil { 160 | newNode.Alias = clone(node.Alias) 161 | } 162 | if node.Content != nil { 163 | newNode.Content = make([]*yaml.Node, len(node.Content)) 164 | for i, child := range node.Content { 165 | newNode.Content[i] = clone(child) 166 | } 167 | } 168 | return newNode 169 | } 170 | -------------------------------------------------------------------------------- /pkg/overlay/apply_test.go: -------------------------------------------------------------------------------- 1 | package overlay_test 2 | 3 | import ( 4 | "bytes" 5 | "github.com/stretchr/testify/assert" 6 | "github.com/stretchr/testify/require" 7 | "gopkg.in/yaml.v3" 8 | "os" 9 | "testing" 10 | ) 11 | 12 | // NodeMatchesFile is a test that marshals the YAML file from the given node, 13 | // then compares those bytes to those found in the expected file. 14 | func NodeMatchesFile( 15 | t *testing.T, 16 | actual *yaml.Node, 17 | expectedFile string, 18 | msgAndArgs ...any, 19 | ) { 20 | variadoc := func(pre ...any) []any { return append(msgAndArgs, pre...) } 21 | 22 | var actualBuf bytes.Buffer 23 | enc := yaml.NewEncoder(&actualBuf) 24 | enc.SetIndent(2) 25 | err := enc.Encode(actual) 26 | require.NoError(t, err, variadoc("failed to marshal node: ")...) 27 | 28 | expectedBytes, err := os.ReadFile(expectedFile) 29 | require.NoError(t, err, variadoc("failed to read expected file: ")...) 30 | 31 | // lazy redo snapshot 32 | //os.WriteFile(expectedFile, actualBuf.Bytes(), 0644) 33 | 34 | //t.Log("### EXPECT START ###\n" + string(expectedBytes) + "\n### EXPECT END ###\n") 35 | //t.Log("### ACTUAL START ###\n" + actualBuf.string() + "\n### ACTUAL END ###\n") 36 | 37 | assert.Equal(t, string(expectedBytes), actualBuf.String(), variadoc("node does not match expected file: ")...) 38 | } 39 | 40 | func TestApplyTo(t *testing.T) { 41 | t.Parallel() 42 | 43 | node, err := LoadSpecification("testdata/openapi.yaml") 44 | require.NoError(t, err) 45 | 46 | o, err := LoadOverlay("testdata/overlay.yaml") 47 | require.NoError(t, err) 48 | 49 | err = o.ApplyTo(node) 50 | assert.NoError(t, err) 51 | 52 | NodeMatchesFile(t, node, "testdata/openapi-overlayed.yaml") 53 | } 54 | -------------------------------------------------------------------------------- /pkg/overlay/compare.go: -------------------------------------------------------------------------------- 1 | package overlay 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "log" 7 | "strings" 8 | 9 | "gopkg.in/yaml.v3" 10 | ) 11 | 12 | // Compare compares input specifications from two files and returns an overlay 13 | // that will convert the first into the second. 14 | func Compare(title string, y1 *yaml.Node, y2 yaml.Node) (*Overlay, error) { 15 | actions, err := walkTreesAndCollectActions(simplePath{}, y1, y2) 16 | if err != nil { 17 | return nil, err 18 | } 19 | 20 | return &Overlay{ 21 | Version: "1.0.0", 22 | Info: Info{ 23 | Title: title, 24 | Version: "0.0.0", 25 | }, 26 | Actions: actions, 27 | }, nil 28 | } 29 | 30 | type simplePart struct { 31 | isKey bool 32 | key string 33 | index int 34 | } 35 | 36 | func intPart(index int) simplePart { 37 | return simplePart{ 38 | index: index, 39 | } 40 | } 41 | 42 | func keyPart(key string) simplePart { 43 | return simplePart{ 44 | isKey: true, 45 | key: key, 46 | } 47 | } 48 | 49 | func (p simplePart) String() string { 50 | if p.isKey { 51 | return fmt.Sprintf("[%q]", p.key) 52 | } 53 | return fmt.Sprintf("[%d]", p.index) 54 | } 55 | 56 | func (p simplePart) KeyString() string { 57 | if p.isKey { 58 | return p.key 59 | } 60 | panic("FIXME: Bug detected in overlay comparison algorithm: attempt to use non key part as key") 61 | } 62 | 63 | type simplePath []simplePart 64 | 65 | func (p simplePath) WithIndex(index int) simplePath { 66 | return append(p, intPart(index)) 67 | } 68 | 69 | func (p simplePath) WithKey(key string) simplePath { 70 | return append(p, keyPart(key)) 71 | } 72 | 73 | func (p simplePath) ToJSONPath() string { 74 | out := &strings.Builder{} 75 | out.WriteString("$") 76 | for _, part := range p { 77 | out.WriteString(part.String()) 78 | } 79 | return out.String() 80 | } 81 | 82 | func (p simplePath) Dir() simplePath { 83 | return p[:len(p)-1] 84 | } 85 | 86 | func (p simplePath) Base() simplePart { 87 | return p[len(p)-1] 88 | } 89 | 90 | func walkTreesAndCollectActions(path simplePath, y1 *yaml.Node, y2 yaml.Node) ([]Action, error) { 91 | if y1 == nil { 92 | return []Action{{ 93 | Target: path.Dir().ToJSONPath(), 94 | Update: y2, 95 | }}, nil 96 | } 97 | 98 | if y2.IsZero() { 99 | return []Action{{ 100 | Target: path.ToJSONPath(), 101 | Remove: true, 102 | }}, nil 103 | } 104 | if y1.Kind != y2.Kind { 105 | return []Action{{ 106 | Target: path.ToJSONPath(), 107 | Update: y2, 108 | }}, nil 109 | } 110 | 111 | switch y1.Kind { 112 | case yaml.DocumentNode: 113 | return walkTreesAndCollectActions(path, y1.Content[0], *y2.Content[0]) 114 | case yaml.SequenceNode: 115 | if len(y2.Content) == len(y1.Content) { 116 | return walkSequenceNode(path, y1, y2) 117 | } 118 | 119 | if len(y2.Content) == len(y1.Content)+1 && 120 | yamlEquals(y2.Content[:len(y1.Content)], y1.Content) { 121 | return []Action{{ 122 | Target: path.ToJSONPath(), 123 | Update: yaml.Node{ 124 | Kind: y1.Kind, 125 | Content: []*yaml.Node{y2.Content[len(y1.Content)]}, 126 | }, 127 | }}, nil 128 | } 129 | 130 | return []Action{{ 131 | Target: path.ToJSONPath() + "[*]", // target all elements 132 | Remove: true, 133 | }, { 134 | Target: path.ToJSONPath(), 135 | Update: yaml.Node{ 136 | Kind: y1.Kind, 137 | Content: y2.Content, 138 | }, 139 | }}, nil 140 | case yaml.MappingNode: 141 | return walkMappingNode(path, y1, y2) 142 | case yaml.ScalarNode: 143 | if y1.Value != y2.Value { 144 | return []Action{{ 145 | Target: path.ToJSONPath(), 146 | Update: y2, 147 | }}, nil 148 | } 149 | case yaml.AliasNode: 150 | log.Println("YAML alias nodes are not yet supported for compare.") 151 | } 152 | return nil, nil 153 | } 154 | 155 | func yamlEquals(nodes []*yaml.Node, content []*yaml.Node) bool { 156 | for i := range nodes { 157 | bufA := &bytes.Buffer{} 158 | bufB := &bytes.Buffer{} 159 | decodeA := yaml.NewEncoder(bufA) 160 | decodeB := yaml.NewEncoder(bufB) 161 | err := decodeA.Encode(nodes[i]) 162 | if err != nil { 163 | return false 164 | } 165 | err = decodeB.Encode(content[i]) 166 | if err != nil { 167 | return false 168 | } 169 | 170 | if bufA.String() != bufB.String() { 171 | return false 172 | } 173 | } 174 | return true 175 | } 176 | 177 | func walkSequenceNode(path simplePath, y1 *yaml.Node, y2 yaml.Node) ([]Action, error) { 178 | nodeLen := max(len(y1.Content), len(y2.Content)) 179 | var actions []Action 180 | for i := 0; i < nodeLen; i++ { 181 | var c1, c2 *yaml.Node 182 | if i < len(y1.Content) { 183 | c1 = y1.Content[i] 184 | } 185 | if i < len(y2.Content) { 186 | c2 = y2.Content[i] 187 | } 188 | 189 | newActions, err := walkTreesAndCollectActions( 190 | path.WithIndex(i), 191 | c1, *c2) 192 | if err != nil { 193 | return nil, err 194 | } 195 | 196 | actions = append(actions, newActions...) 197 | } 198 | 199 | return actions, nil 200 | } 201 | 202 | func walkMappingNode(path simplePath, y1 *yaml.Node, y2 yaml.Node) ([]Action, error) { 203 | var actions []Action 204 | foundKeys := map[string]struct{}{} 205 | 206 | // Add or update keys in y2 that differ/missing from y1 207 | Outer: 208 | for i := 0; i < len(y2.Content); i += 2 { 209 | k2 := y2.Content[i] 210 | v2 := y2.Content[i+1] 211 | 212 | foundKeys[k2.Value] = struct{}{} 213 | 214 | // find keys in y1 to update 215 | for j := 0; j < len(y1.Content); j += 2 { 216 | k1 := y1.Content[j] 217 | v1 := y1.Content[j+1] 218 | 219 | if k1.Value == k2.Value { 220 | newActions, err := walkTreesAndCollectActions( 221 | path.WithKey(k2.Value), 222 | v1, *v2) 223 | if err != nil { 224 | return nil, err 225 | } 226 | actions = append(actions, newActions...) 227 | continue Outer 228 | } 229 | } 230 | 231 | // key not found in y1, so add it 232 | newActions, err := walkTreesAndCollectActions( 233 | path.WithKey(k2.Value), 234 | nil, yaml.Node{ 235 | Kind: y1.Kind, 236 | Content: []*yaml.Node{k2, v2}, 237 | }) 238 | if err != nil { 239 | return nil, err 240 | } 241 | 242 | actions = append(actions, newActions...) 243 | } 244 | 245 | // look for keys in y1 that are not in y2: remove them 246 | for i := 0; i < len(y1.Content); i += 2 { 247 | k1 := y1.Content[i] 248 | 249 | if _, alreadySeen := foundKeys[k1.Value]; alreadySeen { 250 | continue 251 | } 252 | 253 | actions = append(actions, Action{ 254 | Target: path.WithKey(k1.Value).ToJSONPath(), 255 | Remove: true, 256 | }) 257 | } 258 | 259 | return actions, nil 260 | } 261 | -------------------------------------------------------------------------------- /pkg/overlay/compare_test.go: -------------------------------------------------------------------------------- 1 | package overlay_test 2 | 3 | import ( 4 | "fmt" 5 | "github.com/speakeasy-api/jsonpath/pkg/overlay" 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/require" 8 | "gopkg.in/yaml.v3" 9 | "os" 10 | "testing" 11 | ) 12 | 13 | func LoadSpecification(path string) (*yaml.Node, error) { 14 | rs, err := os.Open(path) 15 | if err != nil { 16 | return nil, fmt.Errorf("failed to open schema from path %q: %w", path, err) 17 | } 18 | 19 | var ys yaml.Node 20 | err = yaml.NewDecoder(rs).Decode(&ys) 21 | if err != nil { 22 | return nil, fmt.Errorf("failed to parse schema at path %q: %w", path, err) 23 | } 24 | 25 | return &ys, nil 26 | } 27 | 28 | func LoadOverlay(path string) (*overlay.Overlay, error) { 29 | o, err := overlay.Parse(path) 30 | if err != nil { 31 | return nil, fmt.Errorf("failed to parse overlay from path %q: %w", path, err) 32 | } 33 | 34 | return o, nil 35 | } 36 | 37 | func TestCompare(t *testing.T) { 38 | t.Parallel() 39 | 40 | node, err := LoadSpecification("testdata/openapi.yaml") 41 | require.NoError(t, err) 42 | node2, err := LoadSpecification("testdata/openapi-overlayed.yaml") 43 | require.NoError(t, err) 44 | 45 | o, err := LoadOverlay("testdata/overlay-generated.yaml") 46 | require.NoError(t, err) 47 | 48 | o2, err := overlay.Compare("Drinks Overlay", node, *node2) 49 | assert.NoError(t, err) 50 | 51 | o1s, err := o.ToString() 52 | assert.NoError(t, err) 53 | o2s, err := o2.ToString() 54 | assert.NoError(t, err) 55 | 56 | // Uncomment this if we've improved the output 57 | //os.WriteFile("testdata/overlay-generated.yaml", []byte(o2s), 0644) 58 | assert.Equal(t, o1s, o2s) 59 | 60 | // round trip it 61 | err = o.ApplyTo(node) 62 | assert.NoError(t, err) 63 | NodeMatchesFile(t, node, "testdata/openapi-overlayed.yaml") 64 | 65 | } 66 | -------------------------------------------------------------------------------- /pkg/overlay/parents.go: -------------------------------------------------------------------------------- 1 | package overlay 2 | 3 | import "gopkg.in/yaml.v3" 4 | 5 | type parentIndex map[*yaml.Node]*yaml.Node 6 | 7 | // newParentIndex returns a new parentIndex, populated for the given root node. 8 | func newParentIndex(root *yaml.Node) parentIndex { 9 | index := parentIndex{} 10 | index.indexNodeRecursively(root) 11 | return index 12 | } 13 | 14 | func (index parentIndex) indexNodeRecursively(parent *yaml.Node) { 15 | for _, child := range parent.Content { 16 | index[child] = parent 17 | index.indexNodeRecursively(child) 18 | } 19 | } 20 | 21 | func (index parentIndex) getParent(child *yaml.Node) *yaml.Node { 22 | return index[child] 23 | } 24 | -------------------------------------------------------------------------------- /pkg/overlay/parse.go: -------------------------------------------------------------------------------- 1 | package overlay 2 | 3 | import ( 4 | "fmt" 5 | "gopkg.in/yaml.v3" 6 | "io" 7 | "os" 8 | "path/filepath" 9 | ) 10 | 11 | // Parse will parse the given reader as an overlay file. 12 | func Parse(path string) (*Overlay, error) { 13 | filePath, err := filepath.Abs(path) 14 | if err != nil { 15 | return nil, fmt.Errorf("failed to get absolute path for %q: %w", path, err) 16 | } 17 | 18 | ro, err := os.Open(filePath) 19 | if err != nil { 20 | return nil, fmt.Errorf("failed to open overlay file at path %q: %w", path, err) 21 | } 22 | defer ro.Close() 23 | 24 | var overlay Overlay 25 | dec := yaml.NewDecoder(ro) 26 | 27 | err = dec.Decode(&overlay) 28 | if err != nil { 29 | return nil, err 30 | } 31 | 32 | return &overlay, err 33 | } 34 | 35 | // Format will validate reformat the given file 36 | func Format(path string) error { 37 | overlay, err := Parse(path) 38 | if err != nil { 39 | return err 40 | } 41 | filePath, err := filepath.Abs(path) 42 | if err != nil { 43 | return fmt.Errorf("failed to open overlay file at path %q: %w", path, err) 44 | } 45 | formatted, err := overlay.ToString() 46 | if err != nil { 47 | return err 48 | } 49 | 50 | return os.WriteFile(filePath, []byte(formatted), 0644) 51 | } 52 | 53 | // Format writes the file back out as YAML. 54 | func (o *Overlay) Format(w io.Writer) error { 55 | enc := yaml.NewEncoder(w) 56 | enc.SetIndent(2) 57 | return enc.Encode(o) 58 | } 59 | -------------------------------------------------------------------------------- /pkg/overlay/parse_test.go: -------------------------------------------------------------------------------- 1 | package overlay_test 2 | 3 | import ( 4 | "github.com/speakeasy-api/jsonpath/pkg/overlay" 5 | "github.com/stretchr/testify/assert" 6 | "github.com/stretchr/testify/require" 7 | "os" 8 | "testing" 9 | ) 10 | 11 | var expectOverlay = &overlay.Overlay{ 12 | Extensions: map[string]any{ 13 | "x-top-level-extension": true, 14 | }, 15 | Version: "1.0.0", 16 | Info: overlay.Info{ 17 | Extensions: map[string]any{ 18 | "x-info-extension": 42, 19 | }, 20 | Title: "Drinks Overlay", 21 | Version: "1.2.3", 22 | }, 23 | Extends: "https://raw.githubusercontent.com/speakeasy-sdks/template-sdk/main/openapi.yaml", 24 | Actions: []overlay.Action{ 25 | { 26 | Extensions: map[string]any{ 27 | "x-action-extension": "foo", 28 | }, 29 | Target: `$.paths["/drink/{name}"].get`, 30 | Description: "Test update", 31 | }, 32 | { 33 | Extensions: map[string]any{ 34 | "x-action-extension": "bar", 35 | }, 36 | Target: `$.paths["/drinks"].get`, 37 | Description: "Test remove", 38 | Remove: true, 39 | }, 40 | { 41 | Target: "$.paths[\"/drinks\"]", 42 | }, 43 | { 44 | Target: "$.tags", 45 | }, 46 | }, 47 | } 48 | 49 | func TestParse(t *testing.T) { 50 | err := overlay.Format("testdata/overlay.yaml") 51 | require.NoError(t, err) 52 | o, err := overlay.Parse("testdata/overlay.yaml") 53 | assert.NoError(t, err) 54 | assert.NotNil(t, o) 55 | expect, err := os.ReadFile("testdata/overlay.yaml") 56 | assert.NoError(t, err) 57 | 58 | actual, err := o.ToString() 59 | assert.NoError(t, err) 60 | assert.Equal(t, string(expect), actual) 61 | 62 | } 63 | -------------------------------------------------------------------------------- /pkg/overlay/schema.go: -------------------------------------------------------------------------------- 1 | package overlay 2 | 3 | import ( 4 | "bytes" 5 | "gopkg.in/yaml.v3" 6 | ) 7 | 8 | // Extensible provides a place for extensions to be added to components of the 9 | // Overlay configuration. These are a map from x-* extension fields to their values. 10 | type Extensions map[string]any 11 | 12 | // Overlay is the top-level configuration for an OpenAPI overlay. 13 | type Overlay struct { 14 | Extensions Extensions `yaml:",inline"` 15 | 16 | // Version is the version of the overlay configuration. 17 | Version string `yaml:"overlay"` 18 | 19 | // JSONPathVersion should be set to rfc9535, and is used for backwards compatability purposes 20 | JSONPathVersion string `yaml:"x-speakeasy-jsonpath,omitempty"` 21 | 22 | // Info describes the metadata for the overlay. 23 | Info Info `yaml:"info"` 24 | 25 | // Extends is a URL to the OpenAPI specification this overlay applies to. 26 | Extends string `yaml:"extends,omitempty"` 27 | 28 | // Actions is the list of actions to perform to apply the overlay. 29 | Actions []Action `yaml:"actions"` 30 | } 31 | 32 | func (o *Overlay) ToString() (string, error) { 33 | buf := bytes.NewBuffer([]byte{}) 34 | decoder := yaml.NewEncoder(buf) 35 | decoder.SetIndent(2) 36 | err := decoder.Encode(o) 37 | return buf.String(), err 38 | } 39 | 40 | // Info describes the metadata for the overlay. 41 | type Info struct { 42 | Extensions `yaml:"-,inline"` 43 | 44 | // Title is the title of the overlay. 45 | Title string `yaml:"title"` 46 | 47 | // Version is the version of the overlay. 48 | Version string `yaml:"version"` 49 | } 50 | 51 | type Action struct { 52 | Extensions `yaml:"-,inline"` 53 | 54 | // Target is the JSONPath to the target of the action. 55 | Target string `yaml:"target"` 56 | 57 | // Description is a description of the action. 58 | Description string `yaml:"description,omitempty"` 59 | 60 | // Update is the sub-document to use to merge or replace in the target. This is 61 | // ignored if Remove is set. 62 | Update yaml.Node `yaml:"update,omitempty"` 63 | 64 | // Remove marks the target node for removal rather than update. 65 | Remove bool `yaml:"remove,omitempty"` 66 | } 67 | -------------------------------------------------------------------------------- /pkg/overlay/testdata/openapi-overlayed.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.1.0 2 | info: 3 | title: The Speakeasy Bar 4 | version: 1.0.0 5 | summary: A bar that serves drinks. 6 | description: A secret underground bar that serves drinks to those in the know. 7 | contact: 8 | name: Speakeasy Support 9 | url: https://support.speakeasy.bar 10 | email: support@speakeasy.bar 11 | license: 12 | name: Apache 2.0 13 | url: https://www.apache.org/licenses/LICENSE-2.0.html 14 | termsOfService: https://speakeasy.bar/terms 15 | externalDocs: 16 | url: https://docs.speakeasy.bar 17 | description: The Speakeasy Bar Documentation. 18 | servers: 19 | - url: https://speakeasy.bar 20 | description: The production server. 21 | x-speakeasy-server-id: prod 22 | - url: https://staging.speakeasy.bar 23 | description: The staging server. 24 | x-speakeasy-server-id: staging 25 | - url: https://{organization}.{environment}.speakeasy.bar 26 | description: A per-organization and per-environment API. 27 | x-speakeasy-server-id: customer 28 | variables: 29 | organization: 30 | description: The organization name. Defaults to a generic organization. 31 | default: api 32 | environment: 33 | description: The environment name. Defaults to the production environment. 34 | default: prod 35 | enum: 36 | - prod 37 | - staging 38 | - dev 39 | security: 40 | - apiKey: [] 41 | tags: 42 | - name: drinks 43 | description: The drinks endpoints. 44 | - name: ingredients 45 | description: The ingredients endpoints. 46 | - name: orders 47 | description: The orders endpoints. 48 | - name: authentication 49 | description: The authentication endpoints. 50 | - name: config 51 | - name: Testing 52 | description: just a description 53 | paths: 54 | x-speakeasy-errors: 55 | statusCodes: # Defines status codes to handle as errors for all operations 56 | - 4XX # Wildcard to handle all status codes in the 400-499 range 57 | - 5XX 58 | /anything/selectGlobalServer: 59 | x-my-ignore: 60 | servers: 61 | - url: http://localhost:35123 62 | description: The default server. 63 | get: 64 | operationId: selectGlobalServer 65 | responses: 66 | "200": 67 | description: OK 68 | headers: 69 | X-Optional-Header: 70 | schema: 71 | type: string 72 | x-drop: true 73 | /authenticate: 74 | post: 75 | operationId: authenticate 76 | summary: Authenticate with the API by providing a username and password. 77 | security: [] 78 | tags: 79 | - dont-add-x-drop-false 80 | - authentication 81 | requestBody: 82 | required: true 83 | content: 84 | application/json: 85 | schema: 86 | type: object 87 | properties: 88 | username: 89 | type: string 90 | password: 91 | type: string 92 | responses: 93 | "200": 94 | description: The api key to use for authenticated endpoints. 95 | content: 96 | application/json: 97 | schema: 98 | type: object 99 | properties: 100 | token: 101 | type: string 102 | "401": 103 | description: Invalid credentials provided. 104 | "5XX": 105 | $ref: "#/components/responses/APIError" 106 | default: 107 | $ref: "#/components/responses/UnknownError" 108 | x-drop: false 109 | /drinks: 110 | x-speakeasy-note: 111 | "$ref": "./removeNote.yaml" 112 | /drink/{name}: {} 113 | /ingredients: 114 | get: 115 | operationId: listIngredients 116 | summary: Get a list of ingredients. 117 | description: Get a list of ingredients, if authenticated this will include stock levels and product codes otherwise it will only include public information. 118 | tags: 119 | - ingredients 120 | parameters: 121 | - name: ingredients 122 | in: query 123 | description: A list of ingredients to filter by. If not provided all ingredients will be returned. 124 | required: false 125 | style: form 126 | explode: false 127 | schema: 128 | type: array 129 | items: 130 | type: string 131 | responses: 132 | "200": 133 | description: A list of ingredients. 134 | content: 135 | application/json: 136 | schema: 137 | type: array 138 | items: 139 | $ref: "#/components/schemas/Ingredient" 140 | "5XX": 141 | $ref: "#/components/responses/APIError" 142 | default: 143 | $ref: "#/components/responses/UnknownError" 144 | x-drop: true 145 | /order: 146 | post: 147 | operationId: createOrder 148 | summary: Create an order. 149 | description: Create an order for a drink. 150 | tags: 151 | - orders 152 | parameters: 153 | - name: callback_url 154 | in: query 155 | description: The url to call when the order is updated. 156 | required: false 157 | schema: 158 | type: string 159 | requestBody: 160 | required: true 161 | content: 162 | application/json: 163 | schema: 164 | type: array 165 | items: 166 | $ref: "#/components/schemas/Order" 167 | responses: 168 | "200": 169 | description: The order was created successfully. 170 | content: 171 | application/json: 172 | schema: 173 | $ref: "#/components/schemas/Order" 174 | "5XX": 175 | $ref: "#/components/responses/APIError" 176 | default: 177 | $ref: "#/components/responses/UnknownError" 178 | callbacks: 179 | orderUpdate: 180 | "{$request.query.callback_url}": 181 | post: 182 | summary: Receive order updates. 183 | description: Receive order updates from the supplier, this will be called whenever the status of an order changes. 184 | tags: 185 | - orders 186 | requestBody: 187 | required: true 188 | content: 189 | application/json: 190 | schema: 191 | type: object 192 | properties: 193 | order: 194 | $ref: "#/components/schemas/Order" 195 | responses: 196 | "200": 197 | description: The order update was received successfully. 198 | "5XX": 199 | $ref: "#/components/responses/APIError" 200 | default: 201 | $ref: "#/components/responses/UnknownError" 202 | x-drop: true 203 | /webhooks/subscribe: 204 | post: 205 | operationId: subscribeToWebhooks 206 | summary: Subscribe to webhooks. 207 | description: Subscribe to webhooks. 208 | tags: 209 | - config 210 | requestBody: 211 | required: true 212 | content: 213 | application/json: 214 | schema: 215 | type: array 216 | items: 217 | type: object 218 | properties: 219 | url: 220 | type: string 221 | webhook: 222 | type: string 223 | enum: 224 | - stockUpdate 225 | responses: 226 | "200": 227 | description: The webhook was subscribed to successfully. 228 | "5XX": 229 | $ref: "#/components/responses/APIError" 230 | default: 231 | $ref: "#/components/responses/UnknownError" 232 | x-drop: true 233 | webhooks: 234 | stockUpdate: 235 | post: 236 | summary: Receive stock updates. 237 | description: Receive stock updates from the bar, this will be called whenever the stock levels of a drink or ingredient changes. 238 | tags: 239 | - drinks 240 | - ingredients 241 | requestBody: 242 | required: true 243 | content: 244 | application/json: 245 | schema: 246 | type: object 247 | properties: 248 | drink: 249 | $ref: "#/components/schemas/Drink" 250 | ingredient: 251 | $ref: "#/components/schemas/Ingredient" 252 | responses: 253 | "200": 254 | description: The stock update was received successfully. 255 | "5XX": 256 | $ref: "#/components/responses/APIError" 257 | default: 258 | $ref: "#/components/responses/UnknownError" 259 | components: 260 | schemas: 261 | APIError: 262 | type: object 263 | properties: 264 | code: 265 | type: string 266 | message: 267 | type: string 268 | details: 269 | type: object 270 | additionalProperties: true 271 | Error: 272 | type: object 273 | properties: 274 | code: 275 | type: string 276 | message: 277 | type: string 278 | Drink: 279 | type: object 280 | properties: 281 | name: 282 | description: The name of the drink. 283 | type: string 284 | examples: 285 | - Old Fashioned 286 | - Manhattan 287 | - Negroni 288 | type: 289 | $ref: "#/components/schemas/DrinkType" 290 | price: 291 | description: The price of one unit of the drink in US cents. 292 | type: number 293 | examples: 294 | - 1000 # $10.00 295 | - 1200 # $12.00 296 | - 1500 # $15.00 297 | stock: 298 | description: The number of units of the drink in stock, only available when authenticated. 299 | type: integer 300 | readOnly: true 301 | productCode: 302 | description: The product code of the drink, only available when authenticated. 303 | type: string 304 | examples: 305 | - "AC-A2DF3" 306 | - "NAC-3F2D1" 307 | - "APM-1F2D3" 308 | required: 309 | - name 310 | - price 311 | DrinkType: 312 | description: The type of drink. 313 | type: string 314 | enum: 315 | - cocktail 316 | - non-alcoholic 317 | - beer 318 | - wine 319 | - spirit 320 | - other 321 | Ingredient: 322 | type: object 323 | properties: 324 | name: 325 | description: The name of the ingredient. 326 | type: string 327 | examples: 328 | - Sugar Syrup 329 | - Angostura Bitters 330 | - Orange Peel 331 | type: 332 | $ref: "#/components/schemas/IngredientType" 333 | stock: 334 | description: The number of units of the ingredient in stock, only available when authenticated. 335 | type: integer 336 | examples: 337 | - 10 338 | - 5 339 | - 0 340 | readOnly: true 341 | productCode: 342 | description: The product code of the ingredient, only available when authenticated. 343 | type: string 344 | examples: 345 | - "AC-A2DF3" 346 | - "NAC-3F2D1" 347 | - "APM-1F2D3" 348 | required: 349 | - name 350 | - type 351 | IngredientType: 352 | description: The type of ingredient. 353 | type: string 354 | enum: 355 | - fresh 356 | - long-life 357 | - packaged 358 | Order: 359 | description: An order for a drink or ingredient. 360 | type: object 361 | properties: 362 | type: 363 | $ref: "#/components/schemas/OrderType" 364 | productCode: 365 | description: The product code of the drink or ingredient. 366 | type: string 367 | examples: 368 | - "AC-A2DF3" 369 | - "NAC-3F2D1" 370 | - "APM-1F2D3" 371 | quantity: 372 | description: The number of units of the drink or ingredient to order. 373 | type: integer 374 | minimum: 1 375 | status: 376 | description: The status of the order. 377 | type: string 378 | enum: 379 | - pending 380 | - processing 381 | - complete 382 | readOnly: true 383 | required: 384 | - type 385 | - productCode 386 | - quantity 387 | - status 388 | OrderType: 389 | description: The type of order. 390 | type: string 391 | enum: 392 | - drink 393 | - ingredient 394 | securitySchemes: 395 | apiKey: 396 | type: apiKey 397 | name: Authorization 398 | in: header 399 | responses: 400 | APIError: 401 | description: An error occurred interacting with the API. 402 | content: 403 | application/json: 404 | schema: 405 | $ref: "#/components/schemas/APIError" 406 | UnknownError: 407 | description: An unknown error occurred interacting with the API. 408 | content: 409 | application/json: 410 | schema: 411 | $ref: "#/components/schemas/Error" 412 | -------------------------------------------------------------------------------- /pkg/overlay/testdata/overlay-generated.yaml: -------------------------------------------------------------------------------- 1 | overlay: 1.0.0 2 | info: 3 | title: Drinks Overlay 4 | version: 0.0.0 5 | actions: 6 | - target: $["tags"] 7 | update: 8 | - name: Testing 9 | description: just a description 10 | - target: $["paths"]["/anything/selectGlobalServer"]["x-my-ignore"] 11 | update: 12 | servers: 13 | - url: http://localhost:35123 14 | description: The default server. 15 | - target: $["paths"]["/anything/selectGlobalServer"]["get"] 16 | update: 17 | x-drop: true 18 | - target: $["paths"]["/authenticate"]["post"] 19 | update: 20 | x-drop: false 21 | - target: $["paths"]["/drinks"] 22 | update: 23 | x-speakeasy-note: 24 | "$ref": "./removeNote.yaml" 25 | - target: $["paths"]["/drinks"]["get"] 26 | remove: true 27 | - target: $["paths"]["/drink/{name}"]["get"] 28 | remove: true 29 | - target: $["paths"]["/ingredients"]["get"] 30 | update: 31 | x-drop: true 32 | - target: $["paths"]["/order"]["post"] 33 | update: 34 | x-drop: true 35 | - target: $["paths"]["/webhooks/subscribe"]["post"] 36 | update: 37 | x-drop: true 38 | -------------------------------------------------------------------------------- /pkg/overlay/testdata/overlay.yaml: -------------------------------------------------------------------------------- 1 | overlay: 1.0.0 2 | info: 3 | title: Drinks Overlay 4 | version: 1.2.3 5 | x-info-extension: 42 6 | actions: 7 | - target: $.paths["/drink/{name}"].get 8 | description: Test update 9 | update: 10 | parameters: 11 | - x-parameter-extension: foo 12 | name: test 13 | description: Test parameter 14 | in: query 15 | schema: 16 | type: string 17 | responses: 18 | '200': 19 | x-response-extension: foo 20 | description: Test response 21 | content: 22 | application/json: 23 | schema: 24 | type: string 25 | x-action-extension: foo 26 | - target: $.paths["/drinks"].get 27 | description: Test remove 28 | remove: true 29 | - target: $.paths["/drink/{name}"].get~ 30 | description: Test removing a key -- should delete the node too 31 | remove: true 32 | - target: $.paths["/drinks"] 33 | update: 34 | x-speakeasy-note: 35 | "$ref": "./removeNote.yaml" 36 | - target: $.tags 37 | update: 38 | - name: Testing 39 | description: just a description 40 | - target: $.paths["/anything/selectGlobalServer"]["x-my-ignore"] 41 | update: 42 | servers: 43 | - url: http://localhost:35123 44 | description: The default server. 45 | - target: $.paths.*[?@.operationId] 46 | description: 'add x-drop: true to all paths' 47 | update: 48 | x-drop: true 49 | - target: $.paths.*[?length(@.tags[?(@ == "dont-add-x-drop-false")]) > 0] 50 | description: 'add x-drop: false to any operation which has the dont-add-x-drop-false tag' 51 | update: 52 | x-drop: false 53 | -------------------------------------------------------------------------------- /pkg/overlay/testdata/removeNote.yaml: -------------------------------------------------------------------------------- 1 | this got removed.. -------------------------------------------------------------------------------- /pkg/overlay/validate.go: -------------------------------------------------------------------------------- 1 | package overlay 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "strings" 7 | ) 8 | 9 | type ValidationErrors []error 10 | 11 | func (v ValidationErrors) Error() string { 12 | msgs := make([]string, len(v)) 13 | for i, err := range v { 14 | msgs[i] = err.Error() 15 | } 16 | return strings.Join(msgs, "\n") 17 | } 18 | 19 | func (v ValidationErrors) Return() error { 20 | if len(v) > 0 { 21 | return v 22 | } 23 | return nil 24 | } 25 | 26 | func (o *Overlay) Validate() error { 27 | errs := make(ValidationErrors, 0) 28 | if o.Version != "1.0.0" { 29 | errs = append(errs, fmt.Errorf("overlay version must be 1.0.0")) 30 | } 31 | 32 | if o.Info.Title == "" { 33 | errs = append(errs, fmt.Errorf("overlay info title must be defined")) 34 | } 35 | if o.Info.Version == "" { 36 | errs = append(errs, fmt.Errorf("overlay info version must be defined")) 37 | } 38 | 39 | if o.Extends != "" { 40 | _, err := url.Parse(o.Extends) 41 | if err != nil { 42 | errs = append(errs, fmt.Errorf("overlay extends must be a valid URL")) 43 | } 44 | } 45 | 46 | for i, action := range o.Actions { 47 | if action.Target == "" { 48 | errs = append(errs, fmt.Errorf("overlay action at index %d target must be defined", i)) 49 | } 50 | 51 | if action.Remove && !action.Update.IsZero() { 52 | errs = append(errs, fmt.Errorf("overlay action at index %d should not both set remove and define update", i)) 53 | } 54 | } 55 | 56 | return errs.Return() 57 | } 58 | -------------------------------------------------------------------------------- /web/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['eslint:recommended', 'plugin:react/recommended', 'plugin:react-hooks/recommended', 'plugin:jsx-a11y/recommended'], 3 | plugins: ['@typescript-eslint', 'react', 'react-hooks', 'jsx-a11y', 'prettier'], 4 | parser: '@typescript-eslint/parser', 5 | parserOptions: { 6 | ecmaVersion: 2021, 7 | sourceType: 'module', 8 | ecmaFeatures: { 9 | jsx: true, 10 | }, 11 | }, 12 | rules: { 13 | 'prettier/prettier': 'error', 14 | 'react/jsx-uses-react': 'off', 15 | 'react/react-in-jsx-scope': 'off', 16 | "no-unused-vars": "off", 17 | "no-undef": "off", 18 | "@typescript-eslint/no-unused-vars": ["error", { 19 | 'argsIgnorePattern': '^_', 20 | 'varsIgnorePattern': '^_', 21 | 'caughtErrorsIgnorePattern': '^_' 22 | }] 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /web/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | src/assets/wasm/* 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | .vercel 26 | -------------------------------------------------------------------------------- /web/@/components/ui/progress.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as ProgressPrimitive from "@radix-ui/react-progress"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | 6 | const Progress = React.forwardRef< 7 | React.ElementRef, 8 | React.ComponentPropsWithoutRef 9 | >(({ className, value, ...props }, ref) => ( 10 | 18 | 22 | 23 | )); 24 | Progress.displayName = ProgressPrimitive.Root.displayName; 25 | 26 | export { Progress }; 27 | -------------------------------------------------------------------------------- /web/README.md: -------------------------------------------------------------------------------- 1 | # React + TypeScript + Vite 2 | 3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 4 | 5 | Currently, two official plugins are available: 6 | 7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh 8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh 9 | 10 | ## Expanding the ESLint configuration 11 | 12 | If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: 13 | 14 | - Configure the top-level `parserOptions` property like this: 15 | 16 | ```js 17 | export default { 18 | // other rules... 19 | parserOptions: { 20 | ecmaVersion: 'latest', 21 | sourceType: 'module', 22 | project: ['./tsconfig.json', './tsconfig.node.json'], 23 | tsconfigRootDir: __dirname, 24 | }, 25 | } 26 | ``` 27 | 28 | - Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked` 29 | - Optionally add `plugin:@typescript-eslint/stylistic-type-checked` 30 | - Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list 31 | -------------------------------------------------------------------------------- /web/api/share.ts: -------------------------------------------------------------------------------- 1 | import { put } from "@vercel/blob"; 2 | import { createHash } from "crypto"; 3 | 4 | const MAX_DATA_SIZE = 5 * 1024 * 1024; // 5MB 5 | const AllowedOrigin = process.env.VERCEL_URL 6 | ? `https://${process.env.VERCEL_URL}` 7 | : "http://localhost"; 8 | const productionOrigin = "https://overlay.speakeasy.com"; 9 | 10 | export function POST(request: Request) { 11 | const origin = request.headers.get("Origin"); 12 | 13 | if ( 14 | !origin || 15 | (new URL(origin).hostname != new URL(AllowedOrigin).hostname && 16 | !origin.startsWith(productionOrigin)) 17 | ) { 18 | return new Response("Unauthorized", { status: 403 }); 19 | } 20 | 21 | return new Promise((resolve, reject) => { 22 | const body: ReadableStream | null = request.body; 23 | if (!body) { 24 | reject(new Error("No body")); 25 | return; 26 | } 27 | 28 | const reader = body.getReader(); 29 | const chunks: Uint8Array[] = []; 30 | 31 | const readData = (): void => { 32 | reader.read().then(({ done, value }) => { 33 | if (done) { 34 | processData(chunks).then(resolve).catch(reject); 35 | } else { 36 | chunks.push(value); 37 | if ( 38 | chunks.reduce((acc, chunk) => acc + chunk.length, 0) > MAX_DATA_SIZE 39 | ) { 40 | reject(new Error("Data exceeds the maximum allowed size of 5MB")); 41 | } else { 42 | readData(); 43 | } 44 | } 45 | }); 46 | }; 47 | 48 | readData(); 49 | }); 50 | } 51 | 52 | async function processData(chunks: Uint8Array[]): Promise { 53 | const data = new Uint8Array( 54 | chunks.reduce((acc, chunk) => acc + chunk.length, 0), 55 | ); 56 | let offset = 0; 57 | for (const chunk of chunks) { 58 | data.set(chunk, offset); 59 | offset += chunk.length; 60 | } 61 | 62 | const hash = createHash("sha256").update(data).digest("base64").slice(0, 7); 63 | const key = `share-urls/${hash}`; 64 | 65 | const result = await put(key, data, { 66 | addRandomSuffix: false, 67 | access: "public", 68 | }); 69 | const downloadURL = result.downloadUrl; 70 | const encodedDownloadURL = Buffer.from(downloadURL.trim()).toString("base64"); 71 | 72 | return new Response(JSON.stringify(encodedDownloadURL), { 73 | status: 200, 74 | headers: { "Content-Type": "application/json" }, 75 | }); 76 | } 77 | -------------------------------------------------------------------------------- /web/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "src/style/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | <%- injectScriptHead %> 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | OpenAPI Overlay Playground 20 | 27 | 28 | 29 | <%- injectScriptBody %> 30 |
31 | 32 | 33 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "oas-playground", 3 | "private": true, 4 | "version": "0.0.0", 5 | "packageManager": "pnpm@9.1.1", 6 | "type": "module", 7 | "scripts": { 8 | "dev:vercel": "cd .. && vercel dev", 9 | "dev": "vite", 10 | "build": "tsc && vite build", 11 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 12 | "preview": "vite preview" 13 | }, 14 | "dependencies": { 15 | "@monaco-editor/react": "^4.6.0", 16 | "@radix-ui/react-dropdown-menu": "^2.1.4", 17 | "@radix-ui/react-progress": "^1.1.1", 18 | "@radix-ui/react-slot": "^1.1.1", 19 | "@speakeasy-api/moonshine": "^0.87.0", 20 | "class-variance-authority": "^0.7.1", 21 | "clsx": "^2.1.1", 22 | "jotai": "^2.11.0", 23 | "jotai-location": "^0.5.5", 24 | "lucide-react": "^0.453.0", 25 | "monaco-editor": "0.48.0", 26 | "posthog-js": "^1.205.0", 27 | "react": "^18.3.1", 28 | "react-dom": "^18.3.1", 29 | "react-resizable-panels": "^2.1.7", 30 | "tailwind-merge": "^2.6.0", 31 | "tailwindcss-animate": "^1.0.7", 32 | "usehooks-ts": "^3.1.0", 33 | "yaml": "^2.7.0" 34 | }, 35 | "devDependencies": { 36 | "@types/node": "^22.10.5", 37 | "@types/react": "^18.3.1", 38 | "@types/react-dom": "^18.2.22", 39 | "@typescript-eslint/eslint-plugin": "^7.2.0", 40 | "@typescript-eslint/parser": "^7.2.0", 41 | "@vercel/blob": "^0.27.0", 42 | "@vercel/node": "^5.0.2", 43 | "@vitejs/plugin-react": "^4.2.1", 44 | "autoprefixer": "^10.4.20", 45 | "eslint": "^8.57.0", 46 | "eslint-config-prettier": "^9.1.0", 47 | "eslint-plugin-jsx-a11y": "^6.8.0", 48 | "eslint-plugin-prettier": "^5.1.3", 49 | "eslint-plugin-react": "^7.34.1", 50 | "eslint-plugin-react-hooks": "^4.6.0", 51 | "eslint-plugin-react-refresh": "^0.4.6", 52 | "postcss": "^8.4.49", 53 | "prettier": "^3.2.5", 54 | "tailwindcss": "^3.4.17", 55 | "typescript": "^5.2.2", 56 | "vite": "^5.2.0", 57 | "vite-plugin-html": "^3.2.2", 58 | "vite-plugin-svgr": "^4.3.0", 59 | "vite-plugin-vercel": "^9.0.4" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /web/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /web/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/speakeasy-api/jsonpath/8301a0eab6bbf754758278c368b5b59b7d25368c/web/public/favicon.ico -------------------------------------------------------------------------------- /web/public/og-image-2000x1333.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/speakeasy-api/jsonpath/8301a0eab6bbf754758278c368b5b59b7d25368c/web/public/og-image-2000x1333.png -------------------------------------------------------------------------------- /web/public/og-image-600x900.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/speakeasy-api/jsonpath/8301a0eab6bbf754758278c368b5b59b7d25368c/web/public/og-image-600x900.png -------------------------------------------------------------------------------- /web/src/App.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 20 14.3% 4.1%; 9 | --card: 0 0% 100%; 10 | --card-foreground: 20 14.3% 4.1%; 11 | --popover: 0 0% 100%; 12 | --popover-foreground: 20 14.3% 4.1%; 13 | --primary: 24 9.8% 10%; 14 | --primary-foreground: 60 9.1% 97.8%; 15 | --secondary: 60 4.8% 95.9%; 16 | --secondary-foreground: 24 9.8% 10%; 17 | --muted: 60 4.8% 95.9%; 18 | --muted-foreground: 25 5.3% 44.7%; 19 | --accent: 60 4.8% 95.9%; 20 | --accent-foreground: 24 9.8% 10%; 21 | --destructive: 0 84.2% 60.2%; 22 | --destructive-foreground: 60 9.1% 97.8%; 23 | --border: 20 5.9% 90%; 24 | --input: 20 5.9% 90%; 25 | --ring: 20 14.3% 4.1%; 26 | --chart-1: 12 76% 61%; 27 | --chart-2: 173 58% 39%; 28 | --chart-3: 197 37% 24%; 29 | --chart-4: 43 74% 66%; 30 | --chart-5: 27 87% 67%; 31 | --radius: 0.5rem; 32 | } 33 | @media (prefers-color-scheme: dark) { 34 | :root { 35 | --background: 20 14.3% 4.1%; 36 | --foreground: 60 9.1% 97.8%; 37 | --card: 20 14.3% 4.1%; 38 | --card-foreground: 60 9.1% 97.8%; 39 | --popover: 20 14.3% 4.1%; 40 | --popover-foreground: 60 9.1% 97.8%; 41 | --primary: 60 9.1% 97.8%; 42 | --primary-foreground: 24 9.8% 10%; 43 | --secondary: 12 6.5% 15.1%; 44 | --secondary-foreground: 60 9.1% 97.8%; 45 | --muted: 12 6.5% 15.1%; 46 | --muted-foreground: 24 5.4% 63.9%; 47 | --accent: 12 6.5% 15.1%; 48 | --accent-foreground: 60 9.1% 97.8%; 49 | --destructive: 0 62.8% 30.6%; 50 | --destructive-foreground: 60 9.1% 97.8%; 51 | --border: 12 6.5% 15.1%; 52 | --input: 12 6.5% 15.1%; 53 | --ring: 24 5.7% 82.9%; 54 | --chart-1: 220 70% 50%; 55 | --chart-2: 160 60% 45%; 56 | --chart-3: 30 80% 55%; 57 | --chart-4: 280 65% 60%; 58 | --chart-5: 340 75% 55%; 59 | } 60 | } 61 | 62 | } 63 | @layer base { 64 | * { 65 | @apply border-border; 66 | } 67 | body { 68 | @apply bg-background text-foreground; 69 | display: flex; 70 | margin: 0 auto; 71 | text-align: center; 72 | overflow: hidden; 73 | width: 100vw; 74 | justify-content: space-between; 75 | } 76 | } 77 | 78 | #root > * { 79 | display: flex; 80 | } -------------------------------------------------------------------------------- /web/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { MoonshineConfigProvider } from "@speakeasy-api/moonshine"; 2 | import Playground from "./Playground"; 3 | import "@speakeasy-api/moonshine/moonshine.css"; 4 | 5 | export default function App() { 6 | return ( 7 | 8 | 9 | 10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /web/src/assets/openapi.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /web/src/assets/speakeasy-black.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /web/src/assets/speakeasy-white.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /web/src/assets/wasm/lib.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/speakeasy-api/jsonpath/8301a0eab6bbf754758278c368b5b59b7d25368c/web/src/assets/wasm/lib.wasm -------------------------------------------------------------------------------- /web/src/bridge.ts: -------------------------------------------------------------------------------- 1 | // bridge.ts 2 | import OpenAPIWorker from "./openapi.web.worker.ts?worker"; 3 | 4 | const wasmWorker = new OpenAPIWorker(); 5 | let messageQueue: { 6 | resolve: Function; 7 | reject: Function; 8 | message: any; 9 | supercede: boolean; 10 | }[] = []; 11 | let isProcessing = false; 12 | 13 | function processQueue() { 14 | if (isProcessing || messageQueue.length === 0) return; 15 | 16 | isProcessing = true; 17 | const { resolve, reject, message } = messageQueue.shift()!; 18 | 19 | wasmWorker.postMessage(message); 20 | wasmWorker.onmessage = (event: MessageEvent) => { 21 | if (event.data.type.endsWith("Result")) { 22 | // Reject all superceded messages before resolving the current message 23 | const supercedeMessages = messageQueue.filter((_, index) => { 24 | return messageQueue.slice(index + 1).some((later) => later.supercede); 25 | }); 26 | supercedeMessages.forEach((m) => m.reject(new Error("supercedeerror"))); 27 | messageQueue = messageQueue.filter((m) => !supercedeMessages.includes(m)); 28 | resolve(event.data.payload); 29 | } else if (event.data.type.endsWith("Error")) { 30 | reject(new Error(event.data.error)); 31 | } 32 | isProcessing = false; 33 | processQueue(); 34 | }; 35 | } 36 | 37 | function sendMessage(message: any, supercede = false): Promise { 38 | return new Promise((resolve, reject) => { 39 | messageQueue.push({ resolve, reject, message, supercede }); 40 | processQueue(); 41 | }); 42 | } 43 | 44 | export type CalculateOverlayMessage = { 45 | Request: { 46 | type: "CalculateOverlay"; 47 | payload: { 48 | from: string; 49 | to: string; 50 | existing: string; 51 | }; 52 | }; 53 | Response: 54 | | { 55 | type: "CalculateOverlayResult"; 56 | payload: string; 57 | } 58 | | { 59 | type: "CalculateOverlayError"; 60 | error: string; 61 | }; 62 | }; 63 | 64 | export type GetInfoMessage = { 65 | Request: { 66 | type: "GetInfo"; 67 | payload: { 68 | openapi: string; 69 | }; 70 | }; 71 | Response: 72 | | { 73 | type: "GetInfoResult"; 74 | payload: string; 75 | } 76 | | { 77 | type: "GetInfoError"; 78 | error: string; 79 | }; 80 | }; 81 | 82 | export type ApplyOverlayMessage = { 83 | Request: { 84 | type: "ApplyOverlay"; 85 | payload: { 86 | source: string; 87 | overlay: string; 88 | }; 89 | }; 90 | Response: 91 | | { 92 | type: "ApplyOverlayResult"; 93 | payload: string; 94 | } 95 | | { 96 | type: "ApplyOverlayError"; 97 | error: string; 98 | }; 99 | }; 100 | 101 | export type QueryJSONPathMessage = { 102 | Request: { 103 | type: "QueryJSONPath"; 104 | payload: { 105 | source: string; 106 | jsonpath: string; 107 | }; 108 | }; 109 | Response: 110 | | { 111 | type: "QueryJSONPathResult"; 112 | payload: string; 113 | } 114 | | { 115 | type: "QueryJSONPathError"; 116 | error: string; 117 | }; 118 | }; 119 | 120 | export function CalculateOverlay( 121 | from: string, 122 | to: string, 123 | existing: string, 124 | supercede = false, 125 | ): Promise { 126 | return sendMessage( 127 | { 128 | type: "CalculateOverlay", 129 | payload: { from, to, existing }, 130 | } satisfies CalculateOverlayMessage["Request"], 131 | supercede, 132 | ); 133 | } 134 | 135 | type IncompleteOverlayErrorMessage = { 136 | type: "incomplete"; 137 | line: number; 138 | col: number; 139 | result: string; 140 | }; 141 | 142 | type JSONPathErrorMessage = { 143 | type: "error"; 144 | line: number; 145 | col: number; 146 | error: string; 147 | }; 148 | 149 | type ApplyOverlayResultMessage = { 150 | type: "success"; 151 | result: string; 152 | }; 153 | 154 | type ApplyOverlaySuccess = 155 | | ApplyOverlayResultMessage 156 | | IncompleteOverlayErrorMessage 157 | | JSONPathErrorMessage; 158 | 159 | export async function ApplyOverlay( 160 | source: string, 161 | overlay: string, 162 | supercede = false, 163 | ): Promise { 164 | const result = await sendMessage( 165 | { 166 | type: "ApplyOverlay", 167 | payload: { source, overlay }, 168 | } satisfies ApplyOverlayMessage["Request"], 169 | supercede, 170 | ); 171 | return JSON.parse(result); 172 | } 173 | 174 | export function QueryJSONPath( 175 | source: string, 176 | jsonpath: string, 177 | supercede = false, 178 | ): Promise { 179 | return sendMessage( 180 | { 181 | type: "QueryJSONPath", 182 | payload: { source, jsonpath }, 183 | } satisfies QueryJSONPathMessage["Request"], 184 | supercede, 185 | ); 186 | } 187 | 188 | export function GetInfo(openapi: string, supercede = false): Promise { 189 | return sendMessage( 190 | { 191 | type: "GetInfo", 192 | payload: { openapi }, 193 | } satisfies GetInfoMessage["Request"], 194 | supercede, 195 | ); 196 | } 197 | -------------------------------------------------------------------------------- /web/src/components/CopyButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import { DropdownMenuTriggerProps } from "@radix-ui/react-dropdown-menu"; 5 | import { CheckIcon, ClipboardIcon } from "lucide-react"; 6 | 7 | import { cn } from "@/lib/utils"; 8 | import { Button, ButtonProps } from "@/components/ui/button"; 9 | import { 10 | DropdownMenu, 11 | DropdownMenuContent, 12 | DropdownMenuItem, 13 | DropdownMenuTrigger, 14 | } from "@/components/ui/dropdown-menu"; 15 | import { Input } from "@/components/ui/input"; 16 | 17 | interface CopyButtonProps extends ButtonProps { 18 | value: string; 19 | src?: string; 20 | } 21 | 22 | export async function copyToClipboardWithMeta(value: string) { 23 | navigator.clipboard.writeText(value); 24 | } 25 | 26 | export function CopyButton({ 27 | value, 28 | className, 29 | variant = "ghost", 30 | ...props 31 | }: CopyButtonProps) { 32 | const [hasCopied, setHasCopied] = React.useState(false); 33 | 34 | React.useEffect(() => { 35 | setTimeout(() => { 36 | setHasCopied(false); 37 | }, 5000); 38 | }, [hasCopied]); 39 | 40 | return ( 41 | 65 | ); 66 | } 67 | 68 | interface CopyWithClassNamesProps extends DropdownMenuTriggerProps { 69 | value: string; 70 | classNames: string; 71 | className?: string; 72 | } 73 | 74 | export function CopyWithClassNames({ 75 | value, 76 | classNames, 77 | className, 78 | }: CopyWithClassNamesProps) { 79 | const [hasCopied, setHasCopied] = React.useState(false); 80 | 81 | React.useEffect(() => { 82 | setTimeout(() => { 83 | setHasCopied(false); 84 | }, 2000); 85 | }, [hasCopied]); 86 | 87 | const copyToClipboard = React.useCallback((value: string) => { 88 | copyToClipboardWithMeta(value); 89 | setHasCopied(true); 90 | }, []); 91 | 92 | return ( 93 | 94 | 95 | 110 | 111 | 112 | copyToClipboard(value)}> 113 | Component 114 | 115 | copyToClipboard(classNames)}> 116 | Classname 117 | 118 | 119 | 120 | ); 121 | } 122 | -------------------------------------------------------------------------------- /web/src/components/Editor.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | useCallback, 3 | useEffect, 4 | useMemo, 5 | useRef, 6 | useState, 7 | } from "react"; 8 | import MonacoEditor, { Monaco, DiffEditor } from "@monaco-editor/react"; 9 | import { editor, Uri } from "monaco-editor"; 10 | import { Progress } from "../../@/components/ui/progress"; 11 | import { Icon } from "@speakeasy-api/moonshine"; 12 | import { Button } from "@/components/ui/button"; 13 | import IModelContentChangedEvent = editor.IModelContentChangedEvent; 14 | import ITextModel = editor.ITextModel; 15 | import ICodeEditor = editor.ICodeEditor; 16 | import IStandaloneDiffEditor = editor.IStandaloneDiffEditor; 17 | 18 | export interface EditorComponentProps { 19 | readonly: boolean; 20 | value: string; 21 | original?: string; 22 | loading?: boolean; 23 | title: string; 24 | markers?: editor.IMarkerData[]; 25 | index: number; 26 | maxOnClick?: (index: number) => void; 27 | onChange: ( 28 | value: string | undefined, 29 | ev: editor.IModelContentChangedEvent, 30 | ) => void; 31 | language: DocumentLanguage; 32 | } 33 | 34 | const minLoadingTime = 150; 35 | 36 | export function Editor(props: EditorComponentProps) { 37 | const editorRef = useRef(null); 38 | const monacoRef = useRef(null); 39 | const modelRef = useRef(null); 40 | const [lastLoadingTime, setLastLoadingTime] = useState(minLoadingTime); 41 | const [progress, setProgress] = useState(100); 42 | 43 | const encodedTitle = useMemo(() => { 44 | return btoa(props.title); 45 | }, [props.title]); 46 | 47 | const EditorComponent = 48 | props.original === undefined ? MonacoEditor : DiffEditor; 49 | 50 | const onChange = useCallback( 51 | (value: string, event: IModelContentChangedEvent) => { 52 | props.onChange(value, event); 53 | }, 54 | [props.onChange], 55 | ); 56 | 57 | const handleEditorWillMount = useCallback((monaco: Monaco) => { 58 | monacoRef.current = monaco; 59 | const matchesURI = (uri: Uri | undefined) => { 60 | return uri?.path.includes(encodedTitle); 61 | }; 62 | monaco.editor.onDidCreateModel((model) => { 63 | if (!matchesURI(model.uri)) { 64 | return; 65 | } 66 | if (props.original && !model.uri.path.includes("modified")) { 67 | return; 68 | } 69 | 70 | modelRef.current = model; 71 | modelRef.current.onDidChangeContent((event) => { 72 | if (editorRef.current?.hasTextFocus()) { 73 | onChange(model.getValue(), event); 74 | } 75 | }); 76 | }); 77 | }, []); 78 | 79 | const handleEditorDidMount = useCallback( 80 | (editor: ICodeEditor | IStandaloneDiffEditor, monaco: Monaco) => { 81 | editorRef.current = editor; 82 | 83 | const options = { 84 | base: "vs-dark", 85 | inherit: true, 86 | rules: [ 87 | { 88 | foreground: "F3F0E3", 89 | token: "string", 90 | }, 91 | { 92 | foreground: "679FE1", 93 | token: "type", 94 | }, 95 | ], 96 | colors: { 97 | "editor.foreground": "#F3F0E3", 98 | "editor.background": "#212015", 99 | "editorCursor.foreground": "#679FE1", 100 | "editor.lineHighlightBackground": "#1D2A3A", 101 | "editorLineNumber.foreground": "#6368747F", 102 | "editorLineNumber.activeForeground": "#FBE331", 103 | "editor.inactiveSelectionBackground": "#FF3C742D", 104 | "diffEditor.removedTextBackground": "#FF3C741A", 105 | "diffEditor.insertedTextBackground": "#1D2A3A", 106 | }, 107 | } satisfies editor.IStandaloneThemeData; 108 | monaco.editor.defineTheme("speakeasy", options); 109 | monaco.editor.setTheme("speakeasy"); 110 | }, 111 | [onChange], 112 | ); 113 | 114 | const options: any = useMemo( 115 | () => ({ 116 | readOnly: props.readonly, 117 | minimap: { enabled: false }, 118 | automaticLayout: true, 119 | renderSideBySide: false, 120 | }), 121 | [props.readonly], 122 | ); 123 | const isLoading = useMemo( 124 | () => props.loading || progress < 100, 125 | [props.loading, progress], 126 | ); 127 | 128 | const onMaxClick = useCallback(() => { 129 | props.maxOnClick?.(props.index); 130 | }, [props.maxOnClick, props.index]); 131 | 132 | useEffect(() => { 133 | if (props.loading) { 134 | const startTime = Date.now(); 135 | 136 | return () => { 137 | const endTime = Date.now(); 138 | setLastLoadingTime(Math.max(minLoadingTime, endTime - startTime)); 139 | }; 140 | } 141 | }, [props.loading]); 142 | 143 | useEffect(() => { 144 | if (isLoading) { 145 | const timer = setInterval(() => { 146 | setProgress((prevProgress) => { 147 | if (prevProgress >= 100) { 148 | clearInterval(timer); 149 | return 100; 150 | } 151 | return prevProgress + 10; 152 | }); 153 | }, lastLoadingTime / 10); 154 | setProgress(0); 155 | } 156 | }, [isLoading]); 157 | 158 | useEffect(() => { 159 | if (modelRef?.current) { 160 | monacoRef.current?.editor?.setModelMarkers( 161 | modelRef?.current, 162 | "diagnostics", 163 | props.markers || [], 164 | ); 165 | } 166 | }, [props.markers]); 167 | 168 | const wrapperStyles = useMemo(() => { 169 | if (isLoading) { 170 | return { 171 | width: "100%", 172 | height: "100%", 173 | filter: "blur(1px)", 174 | position: "relative", 175 | } satisfies React.CSSProperties; 176 | } 177 | return { 178 | width: "100%", 179 | height: "100%", 180 | position: "relative", 181 | } satisfies React.CSSProperties; 182 | }, [isLoading]); 183 | 184 | return ( 185 |
186 | {progress < 100 && minLoadingTime < lastLoadingTime && ( 187 | 192 | )} 193 | {props.title && ( 194 |
195 |

196 | {props.title} 197 | {props.maxOnClick ? ( 198 | 206 | ) : null} 207 |

208 |
209 | )} 210 | 223 |
224 | ); 225 | } 226 | -------------------------------------------------------------------------------- /web/src/components/FileUpload.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useRef } from "react"; 2 | import { Icon } from "@speakeasy-api/moonshine"; 3 | import type { Attachment } from "@speakeasy-api/moonshine/dist/components/PromptInput"; 4 | import { cn } from "@/lib/utils"; 5 | 6 | export default function FileUpload(props: { 7 | onFileUpload: (content: string) => void; 8 | }) { 9 | const fileInputRef = useRef(null); 10 | 11 | const handleFileUpload = useCallback( 12 | (files: Attachment[]) => { 13 | if (files.length > 0) { 14 | // Convert bytes to string 15 | const buffer = new Uint8Array(files[0].bytes); 16 | const content = new TextDecoder().decode(buffer); 17 | props.onFileUpload(content); 18 | } 19 | }, 20 | [props.onFileUpload], 21 | ); 22 | 23 | const handleFiles = useCallback( 24 | async (files: File[]) => { 25 | const attachments: Attachment[] = await Promise.all( 26 | files.map(async (file) => ({ 27 | id: crypto.randomUUID(), 28 | name: file.name, 29 | type: file.type, 30 | size: file.size, 31 | bytes: await file.arrayBuffer(), 32 | })), 33 | ); 34 | handleFileUpload(attachments); 35 | }, 36 | [handleFileUpload], 37 | ); 38 | 39 | return ( 40 |
41 | {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions,jsx-a11y/click-events-have-key-events */} 42 |
fileInputRef.current?.click()} 48 | > 49 | handleFiles(Array.from(e.target.files ?? []))} 54 | accept={[ 55 | "application/yaml", 56 | "application/x-yaml", 57 | "application/json", 58 | ].join(",")} 59 | /> 60 | 65 | Upload file 66 |
67 |
68 | ); 69 | } 70 | -------------------------------------------------------------------------------- /web/src/components/ShareButton.tsx: -------------------------------------------------------------------------------- 1 | import { Icon } from "@speakeasy-api/moonshine"; 2 | import { cn } from "@/lib/utils"; 3 | import { Loader2Icon } from "lucide-react"; 4 | 5 | export default function ShareButton(props: { 6 | onClick: () => void; 7 | loading: boolean; 8 | }) { 9 | return ( 10 |
11 | {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events */} 12 |
20 | {props.loading ? ( 21 | 22 | ) : ( 23 | 31 | )} 32 | Share 33 |
34 |
35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /web/src/components/ShareDialog.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Button, 3 | Dialog, 4 | Heading, 5 | Separator, 6 | Stack, 7 | Text, 8 | } from "@speakeasy-api/moonshine"; 9 | import { forwardRef, useImperativeHandle, useState } from "react"; 10 | import { CopyButton } from "./CopyButton"; 11 | 12 | export interface ShareDialogHandle { 13 | setUrl: React.Dispatch>; 14 | setOpen: React.Dispatch>; 15 | } 16 | 17 | const ShareDialog = forwardRef((_, ref) => { 18 | const [url, setUrl] = useState(""); 19 | const [open, setOpen] = useState(false); 20 | 21 | useImperativeHandle(ref, () => ({ 22 | setUrl, 23 | setOpen, 24 | })); 25 | 26 | const handleClose = () => { 27 | setOpen(false); 28 | }; 29 | 30 | const handleOpenChange = (open: boolean) => { 31 | setOpen(open); 32 | }; 33 | 34 | return ( 35 | 36 | 37 | 38 | 39 |
40 | Share 41 | 42 | Copy and paste the URL below anywhere to share this overlay 43 | session with others. 44 | 45 |
46 |
47 |
48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 |
59 |
60 | ); 61 | }); 62 | ShareDialog.displayName = "ShareDialog"; 63 | 64 | export default ShareDialog; 65 | -------------------------------------------------------------------------------- /web/src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 13 | destructive: 14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90", 15 | outline: 16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 17 | secondary: 18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 19 | ghost: "hover:bg-accent hover:text-accent-foreground", 20 | link: "text-primary underline-offset-4 hover:underline", 21 | }, 22 | size: { 23 | default: "h-10 px-4 py-2", 24 | sm: "h-9 rounded-md px-3", 25 | lg: "h-11 rounded-md px-8", 26 | icon: "h-10 w-10", 27 | }, 28 | }, 29 | defaultVariants: { 30 | variant: "default", 31 | size: "default", 32 | }, 33 | } 34 | ) 35 | 36 | export interface ButtonProps 37 | extends React.ButtonHTMLAttributes, 38 | VariantProps { 39 | asChild?: boolean 40 | } 41 | 42 | const Button = React.forwardRef( 43 | ({ className, variant, size, asChild = false, ...props }, ref) => { 44 | const Comp = asChild ? Slot : "button" 45 | return ( 46 | 51 | ) 52 | } 53 | ) 54 | Button.displayName = "Button" 55 | 56 | export { Button, buttonVariants } 57 | -------------------------------------------------------------------------------- /web/src/components/ui/dropdown-menu.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" 3 | import { Check, ChevronRight, Circle } from "lucide-react" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const DropdownMenu = DropdownMenuPrimitive.Root 8 | 9 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger 10 | 11 | const DropdownMenuGroup = DropdownMenuPrimitive.Group 12 | 13 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal 14 | 15 | const DropdownMenuSub = DropdownMenuPrimitive.Sub 16 | 17 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup 18 | 19 | const DropdownMenuSubTrigger = React.forwardRef< 20 | React.ElementRef, 21 | React.ComponentPropsWithoutRef & { 22 | inset?: boolean 23 | } 24 | >(({ className, inset, children, ...props }, ref) => ( 25 | 34 | {children} 35 | 36 | 37 | )) 38 | DropdownMenuSubTrigger.displayName = 39 | DropdownMenuPrimitive.SubTrigger.displayName 40 | 41 | const DropdownMenuSubContent = React.forwardRef< 42 | React.ElementRef, 43 | React.ComponentPropsWithoutRef 44 | >(({ className, ...props }, ref) => ( 45 | 53 | )) 54 | DropdownMenuSubContent.displayName = 55 | DropdownMenuPrimitive.SubContent.displayName 56 | 57 | const DropdownMenuContent = React.forwardRef< 58 | React.ElementRef, 59 | React.ComponentPropsWithoutRef 60 | >(({ className, sideOffset = 4, ...props }, ref) => ( 61 | 62 | 71 | 72 | )) 73 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName 74 | 75 | const DropdownMenuItem = React.forwardRef< 76 | React.ElementRef, 77 | React.ComponentPropsWithoutRef & { 78 | inset?: boolean 79 | } 80 | >(({ className, inset, ...props }, ref) => ( 81 | 90 | )) 91 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName 92 | 93 | const DropdownMenuCheckboxItem = React.forwardRef< 94 | React.ElementRef, 95 | React.ComponentPropsWithoutRef 96 | >(({ className, children, checked, ...props }, ref) => ( 97 | 106 | 107 | 108 | 109 | 110 | 111 | {children} 112 | 113 | )) 114 | DropdownMenuCheckboxItem.displayName = 115 | DropdownMenuPrimitive.CheckboxItem.displayName 116 | 117 | const DropdownMenuRadioItem = React.forwardRef< 118 | React.ElementRef, 119 | React.ComponentPropsWithoutRef 120 | >(({ className, children, ...props }, ref) => ( 121 | 129 | 130 | 131 | 132 | 133 | 134 | {children} 135 | 136 | )) 137 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName 138 | 139 | const DropdownMenuLabel = React.forwardRef< 140 | React.ElementRef, 141 | React.ComponentPropsWithoutRef & { 142 | inset?: boolean 143 | } 144 | >(({ className, inset, ...props }, ref) => ( 145 | 154 | )) 155 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName 156 | 157 | const DropdownMenuSeparator = React.forwardRef< 158 | React.ElementRef, 159 | React.ComponentPropsWithoutRef 160 | >(({ className, ...props }, ref) => ( 161 | 166 | )) 167 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName 168 | 169 | const DropdownMenuShortcut = ({ 170 | className, 171 | ...props 172 | }: React.HTMLAttributes) => { 173 | return ( 174 | 178 | ) 179 | } 180 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut" 181 | 182 | export { 183 | DropdownMenu, 184 | DropdownMenuTrigger, 185 | DropdownMenuContent, 186 | DropdownMenuItem, 187 | DropdownMenuCheckboxItem, 188 | DropdownMenuRadioItem, 189 | DropdownMenuLabel, 190 | DropdownMenuSeparator, 191 | DropdownMenuShortcut, 192 | DropdownMenuGroup, 193 | DropdownMenuPortal, 194 | DropdownMenuSub, 195 | DropdownMenuSubContent, 196 | DropdownMenuSubTrigger, 197 | DropdownMenuRadioGroup, 198 | } 199 | -------------------------------------------------------------------------------- /web/src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Input = React.forwardRef>( 6 | ({ className, type, ...props }, ref) => { 7 | return ( 8 | 17 | ) 18 | } 19 | ) 20 | Input.displayName = "Input" 21 | 22 | export { Input } 23 | -------------------------------------------------------------------------------- /web/src/compress.ts: -------------------------------------------------------------------------------- 1 | export const compress = async (decodedString: string): Promise => { 2 | try { 3 | const stream = new Blob([decodedString]).stream(); 4 | const compressedStream: ReadableStream = stream.pipeThrough( 5 | new CompressionStream("gzip"), 6 | ); 7 | const reader = compressedStream.getReader(); 8 | const chunks = []; 9 | for ( 10 | let chunk = await reader.read(); 11 | !chunk.done; 12 | chunk = await reader.read() 13 | ) { 14 | chunks.push(chunk.value); 15 | } 16 | const blob = new Blob(chunks); 17 | 18 | return blob; 19 | } catch (e: any) { 20 | throw new Error("failed to compress string: " + e.message); 21 | } 22 | }; 23 | 24 | export const decompress = async ( 25 | stream: ReadableStream, 26 | ): Promise => { 27 | const decompressedStream = stream.pipeThrough( 28 | new DecompressionStream("gzip"), 29 | ); 30 | const decompressedReader = decompressedStream.getReader(); 31 | const decompressedChunks = []; 32 | for ( 33 | let chunk = await decompressedReader.read(); 34 | !chunk.done; 35 | chunk = await decompressedReader.read() 36 | ) { 37 | decompressedChunks.push(chunk.value); 38 | } 39 | 40 | const blob = new Blob(decompressedChunks); 41 | return blob.text(); 42 | }; 43 | -------------------------------------------------------------------------------- /web/src/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; 3 | line-height: 1.5; 4 | font-weight: 400; 5 | 6 | color-scheme: light dark; 7 | color: rgba(255, 255, 255, 0.87); 8 | background-color: #242424; 9 | 10 | font-synthesis: none; 11 | text-rendering: optimizeLegibility; 12 | -webkit-font-smoothing: antialiased; 13 | -moz-osx-font-smoothing: grayscale; 14 | } 15 | 16 | a { 17 | font-weight: 500; 18 | color: #646cff; 19 | text-decoration: inherit; 20 | } 21 | a:hover { 22 | color: #535bf2; 23 | } 24 | 25 | body { 26 | margin: 0; 27 | display: flex; 28 | place-items: center; 29 | min-width: 320px; 30 | min-height: 100vh; 31 | } 32 | 33 | h1 { 34 | font-size: 3.2em; 35 | line-height: 1.1; 36 | } 37 | 38 | button { 39 | border: 1px solid transparent; 40 | transition: border-color 0.25s; 41 | } 42 | -------------------------------------------------------------------------------- /web/src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | 8 | export function guessDocumentLanguage(content: string): DocumentLanguage { 9 | try { 10 | JSON.parse(content); 11 | return "json"; 12 | } catch { 13 | return "yaml"; 14 | } 15 | } 16 | 17 | export function formatDocument(doc: string, indentWidth: number = 2): string { 18 | const docLang = guessDocumentLanguage(doc); 19 | 20 | if (docLang === "json") 21 | return JSON.stringify(JSON.parse(doc), null, indentWidth); 22 | 23 | return doc; 24 | } 25 | 26 | export function arraysEqual(a: T[], b: T[]): boolean { 27 | // Check if the arrays have the same length 28 | if (a.length !== b.length) { 29 | return false; 30 | } 31 | 32 | // Compare each element in the arrays 33 | for (let i = 0; i < a.length; i++) { 34 | if (a[i] !== b[i]) { 35 | return false; 36 | } 37 | } 38 | 39 | return true; 40 | } 41 | -------------------------------------------------------------------------------- /web/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import App from "./App"; 4 | import "./index.css"; 5 | import posthog from "posthog-js"; 6 | 7 | if (process.env.PUBLIC_POSTHOG_API_KEY) { 8 | posthog.init(process.env.PUBLIC_POSTHOG_API_KEY, { 9 | api_host: "https://metrics.speakeasy.com", 10 | disable_session_recording: true, 11 | autocapture: false, 12 | before_send: function (event) { 13 | if (event?.properties?.$current_url) { 14 | const url = new URL(event.properties.$current_url); 15 | url.hash = ""; 16 | event.properties.$current_url = url.toString(); 17 | } 18 | return event; 19 | }, 20 | }); 21 | } 22 | 23 | ReactDOM.createRoot(document.getElementById("root")!).render( 24 | 25 | 26 | , 27 | ); 28 | -------------------------------------------------------------------------------- /web/src/openapi.web.worker.ts: -------------------------------------------------------------------------------- 1 | // openapi.web.worker.ts 2 | import speakeasyWASM from "./assets/wasm/lib.wasm?url"; 3 | import "./assets/wasm/wasm_exec.js"; 4 | import type { 5 | CalculateOverlayMessage, 6 | ApplyOverlayMessage, 7 | GetInfoMessage, 8 | QueryJSONPathMessage, 9 | } from "./bridge"; 10 | 11 | const _wasmExecutors = { 12 | CalculateOverlay: (..._: any): any => false, 13 | ApplyOverlay: (..._: any): any => false, 14 | GetInfo: (..._: any): any => false, 15 | QueryJSONPath: (..._: any): any => false, 16 | } as const; 17 | 18 | type MessageHandlers = { 19 | [K in keyof typeof _wasmExecutors]: (payload: any) => Promise; 20 | }; 21 | 22 | const messageHandlers: MessageHandlers = { 23 | CalculateOverlay: async ( 24 | payload: CalculateOverlayMessage["Request"]["payload"], 25 | ) => { 26 | return exec("CalculateOverlay", payload.from, payload.to, payload.existing); 27 | }, 28 | ApplyOverlay: async (payload: ApplyOverlayMessage["Request"]["payload"]) => { 29 | return exec("ApplyOverlay", payload.source, payload.overlay); 30 | }, 31 | GetInfo: async (payload: GetInfoMessage["Request"]["payload"]) => { 32 | return exec("GetInfo", payload.openapi); 33 | }, 34 | QueryJSONPath: async ( 35 | payload: QueryJSONPathMessage["Request"]["payload"], 36 | ) => { 37 | return exec("QueryJSONPath", payload.source, payload.jsonpath); 38 | }, 39 | }; 40 | 41 | let instantiated = false; 42 | 43 | async function Instantiate() { 44 | if (instantiated) { 45 | return; 46 | } 47 | const go = new Go(); 48 | const result = await WebAssembly.instantiateStreaming( 49 | fetch(speakeasyWASM), 50 | go.importObject, 51 | ); 52 | go.run(result.instance); 53 | for (const funcName of Object.keys(_wasmExecutors)) { 54 | // @ts-ignore 55 | if (!globalThis[funcName]) { 56 | throw new Error("missing expected function " + funcName); 57 | } 58 | // @ts-ignore 59 | _wasmExecutors[funcName] = globalThis[funcName]; 60 | } 61 | instantiated = true; 62 | } 63 | 64 | async function exec(funcName: keyof typeof _wasmExecutors, ...args: any) { 65 | if (!instantiated) { 66 | await Instantiate(); 67 | } 68 | if (!_wasmExecutors[funcName]) { 69 | throw new Error("not defined"); 70 | } 71 | return _wasmExecutors[funcName](...args); 72 | } 73 | 74 | self.onmessage = async ( 75 | event: MessageEvent< 76 | CalculateOverlayMessage["Request"] | ApplyOverlayMessage["Request"] 77 | >, 78 | ) => { 79 | const { type, payload } = event.data; 80 | try { 81 | const handler = messageHandlers[type]; 82 | if (handler) { 83 | const result = await handler(payload); 84 | self.postMessage({ type: `${type}Result`, payload: result }); 85 | } else { 86 | throw new Error(`Unknown message type: ${type}`); 87 | } 88 | } catch (err: any) { 89 | if (err && err.message) { 90 | self.postMessage({ type: `${type}Error`, error: err.message }); 91 | } else { 92 | self.postMessage({ type: `${type}Error`, error: "unknown error" }); 93 | } 94 | } 95 | }; 96 | -------------------------------------------------------------------------------- /web/src/types.d.ts: -------------------------------------------------------------------------------- 1 | declare class Go { 2 | argv: string[]; 3 | env: { [envKey: string]: string }; 4 | exit: (code: number) => void; 5 | importObject: WebAssembly.Imports; 6 | exited: boolean; 7 | mem: DataView; 8 | run(instance: WebAssembly.Instance): Promise; 9 | } 10 | 11 | declare type DocumentLanguage = "json" | "yaml"; 12 | -------------------------------------------------------------------------------- /web/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /web/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | darkMode: ["class"], 4 | content: [ 5 | "./pages/**/*.{ts,tsx}", 6 | "./components/**/*.{ts,tsx}", 7 | "./app/**/*.{ts,tsx}", 8 | "./src/**/*.{ts,tsx}", 9 | ], 10 | prefix: "", 11 | theme: { 12 | container: { 13 | center: true, 14 | padding: '2rem', 15 | screens: { 16 | '2xl': '1400px' 17 | } 18 | }, 19 | extend: { 20 | colors: { 21 | border: 'hsl(var(--border))', 22 | input: 'hsl(var(--input))', 23 | ring: 'hsl(var(--ring))', 24 | background: 'hsl(var(--background))', 25 | foreground: 'hsl(var(--foreground))', 26 | primary: { 27 | DEFAULT: 'hsl(var(--primary))', 28 | foreground: 'hsl(var(--primary-foreground))' 29 | }, 30 | secondary: { 31 | DEFAULT: 'hsl(var(--secondary))', 32 | foreground: 'hsl(var(--secondary-foreground))' 33 | }, 34 | destructive: { 35 | DEFAULT: 'hsl(var(--destructive))', 36 | foreground: 'hsl(var(--destructive-foreground))' 37 | }, 38 | muted: { 39 | DEFAULT: 'hsl(var(--muted))', 40 | foreground: 'hsl(var(--muted-foreground))' 41 | }, 42 | accent: { 43 | DEFAULT: 'hsl(var(--accent))', 44 | foreground: 'hsl(var(--accent-foreground))' 45 | }, 46 | popover: { 47 | DEFAULT: 'hsl(var(--popover))', 48 | foreground: 'hsl(var(--popover-foreground))' 49 | }, 50 | card: { 51 | DEFAULT: 'hsl(var(--card))', 52 | foreground: 'hsl(var(--card-foreground))' 53 | }, 54 | chart: { 55 | '1': 'hsl(var(--chart-1))', 56 | '2': 'hsl(var(--chart-2))', 57 | '3': 'hsl(var(--chart-3))', 58 | '4': 'hsl(var(--chart-4))', 59 | '5': 'hsl(var(--chart-5))' 60 | } 61 | }, 62 | borderRadius: { 63 | lg: 'var(--radius)', 64 | md: 'calc(var(--radius) - 2px)', 65 | sm: 'calc(var(--radius) - 4px)' 66 | }, 67 | keyframes: { 68 | 'accordion-down': { 69 | from: { 70 | height: '0' 71 | }, 72 | to: { 73 | height: 'var(--radix-accordion-content-height)' 74 | } 75 | }, 76 | 'accordion-up': { 77 | from: { 78 | height: 'var(--radix-accordion-content-height)' 79 | }, 80 | to: { 81 | height: '0' 82 | } 83 | }, 84 | gradientAnimation: { 85 | '0%': { 86 | backgroundPosition: '0% 50%' 87 | }, 88 | '100%': { 89 | backgroundPosition: '100% 50%' 90 | } 91 | } 92 | }, 93 | animation: { 94 | 'accordion-down': 'accordion-down 0.2s ease-out', 95 | 'accordion-up': 'accordion-up 0.2s ease-out', 96 | gradient: 'gradientAnimation 1s linear infinite alternate' 97 | } 98 | } 99 | }, 100 | plugins: [require("tailwindcss-animate")], 101 | }; 102 | -------------------------------------------------------------------------------- /web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "node", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "noEmit": true, 14 | "jsx": "react-jsx", 15 | 16 | /* Linting */ 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "allowSyntheticDefaultImports": true, 22 | "paths": { 23 | "@/*": ["./src/*"] 24 | } 25 | }, 26 | "include": ["src"], 27 | "references": [{ "path": "./tsconfig.node.json" }], 28 | 29 | } 30 | -------------------------------------------------------------------------------- /web/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true, 8 | "strict": true 9 | }, 10 | "include": ["vite.config.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /web/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react"; 3 | import svgr from "vite-plugin-svgr"; 4 | import path from "path"; 5 | import vercel from "vite-plugin-vercel"; 6 | import { createHtmlPlugin } from "vite-plugin-html"; 7 | 8 | const htmlPlugin = createHtmlPlugin({ 9 | minify: true, 10 | entry: "src/main.tsx", 11 | inject: 12 | process.env.ANALYTICS_SCRIPT_HEAD && process.env.ANALYTICS_SCRIPT_BODY 13 | ? { 14 | data: { 15 | injectScriptHead: process.env.ANALYTICS_SCRIPT_HEAD, 16 | injectScriptBody: process.env.ANALYTICS_SCRIPT_BODY, 17 | }, 18 | } 19 | : { 20 | data: { 21 | injectScriptHead: ``, 22 | injectScriptBody: ``, 23 | }, 24 | }, 25 | }); 26 | 27 | // https://vitejs.dev/config/ 28 | export default defineConfig({ 29 | plugins: [react(), svgr(), vercel(), htmlPlugin], 30 | define: { 31 | // add nodejs shims: moonshine requires them. 32 | global: {}, 33 | // importing moonshine error'd without this. 34 | "process.env.VSCODE_TEXTMATE_DEBUG": "false", 35 | "process.env.PUBLIC_POSTHOG_API_KEY": JSON.stringify( 36 | process.env.PUBLIC_POSTHOG_API_KEY, 37 | ), 38 | }, 39 | resolve: { 40 | alias: { 41 | "@": path.resolve(__dirname, "./src"), 42 | }, 43 | }, 44 | }); 45 | --------------------------------------------------------------------------------