├── .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 |
17 |
18 | # jsonpath
19 |
20 |
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 | {
49 | copyToClipboardWithMeta(value);
50 | setHasCopied(true);
51 | }}
52 | style={{ background: "transparent" }}
53 | {...props}
54 | >
55 |
60 |
61 | Copy
62 | {hasCopied ? : }
63 |
64 |
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 |
103 | {hasCopied ? (
104 |
105 | ) : (
106 |
107 | )}
108 | Copy
109 |
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 |
204 |
205 |
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 | Done
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 |
--------------------------------------------------------------------------------