From 36e7cb086dd8f306e33b3478c17119520df4223b Mon Sep 17 00:00:00 2001 From: Tristan Cartledge Date: Fri, 15 Aug 2025 16:18:20 +1000 Subject: [PATCH 1/5] feat: introduce overlay support (migration from https://github.com/speakeasy-api/openapi-overlay) --- .github/workflows/ci.yaml | 4 +- .github/workflows/commits.yml | 4 +- .golangci.yaml | 1 + README.md | 31 +- cmd/update-examples/main.go | 4 +- go.mod | 5 + go.sum | 47 ++ overlay/README.md | 294 +++++++++++ overlay/apply.go | 238 +++++++++ overlay/apply_test.go | 310 ++++++++++++ overlay/cmd/README.md | 51 ++ overlay/cmd/apply.go | 46 ++ overlay/cmd/compare.go | 43 ++ overlay/cmd/root.go | 21 + overlay/cmd/shared.go | 16 + overlay/cmd/validate.go | 31 ++ overlay/compare.go | 265 ++++++++++ overlay/compare_test.go | 40 ++ overlay/jsonpath.go | 48 ++ overlay/loader/overlay.go | 18 + overlay/loader/spec.go | 80 +++ overlay/overlay_examples_test.go | 293 +++++++++++ overlay/parents.go | 23 + overlay/parse.go | 59 +++ overlay/parse_test.go | 27 ++ overlay/schema.go | 67 +++ overlay/testdata/openapi-overlayed.yaml | 441 +++++++++++++++++ .../testdata/openapi-strict-onechange.yaml | 452 +++++++++++++++++ overlay/testdata/openapi.yaml | 457 ++++++++++++++++++ overlay/testdata/overlay-generated.yaml | 54 +++ overlay/testdata/overlay-mismatched.yaml | 16 + overlay/testdata/overlay-old.yaml | 8 + overlay/testdata/overlay-zero-change.yaml | 10 + overlay/testdata/overlay.yaml | 53 ++ overlay/testdata/removeNote.yaml | 1 + overlay/utils.go | 20 + overlay/validate.go | 62 +++ 37 files changed, 3608 insertions(+), 32 deletions(-) create mode 100644 overlay/README.md create mode 100644 overlay/apply.go create mode 100644 overlay/apply_test.go create mode 100644 overlay/cmd/README.md create mode 100644 overlay/cmd/apply.go create mode 100644 overlay/cmd/compare.go create mode 100644 overlay/cmd/root.go create mode 100644 overlay/cmd/shared.go create mode 100644 overlay/cmd/validate.go create mode 100644 overlay/compare.go create mode 100644 overlay/compare_test.go create mode 100644 overlay/jsonpath.go create mode 100644 overlay/loader/overlay.go create mode 100644 overlay/loader/spec.go create mode 100644 overlay/overlay_examples_test.go create mode 100644 overlay/parents.go create mode 100644 overlay/parse.go create mode 100644 overlay/parse_test.go create mode 100644 overlay/schema.go create mode 100644 overlay/testdata/openapi-overlayed.yaml create mode 100644 overlay/testdata/openapi-strict-onechange.yaml create mode 100644 overlay/testdata/openapi.yaml create mode 100644 overlay/testdata/overlay-generated.yaml create mode 100644 overlay/testdata/overlay-mismatched.yaml create mode 100644 overlay/testdata/overlay-old.yaml create mode 100644 overlay/testdata/overlay-zero-change.yaml create mode 100644 overlay/testdata/overlay.yaml create mode 100644 overlay/testdata/removeNote.yaml create mode 100644 overlay/utils.go create mode 100644 overlay/validate.go diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index cfc5bd0..eaabf33 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -11,7 +11,7 @@ jobs: name: Lint and Format Check runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v4 - name: Install mise uses: jdx/mise-action@v2 @@ -45,7 +45,7 @@ jobs: pull-requests: write steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v4 - name: Install mise uses: jdx/mise-action@v2 diff --git a/.github/workflows/commits.yml b/.github/workflows/commits.yml index 62ad13c..e0a68e3 100644 --- a/.github/workflows/commits.yml +++ b/.github/workflows/commits.yml @@ -13,8 +13,8 @@ jobs: name: Conventional Commits runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v4 - uses: webiny/action-conventional-commits@v1.3.0 - - uses: amannn/action-semantic-pull-request@v6.0.1 + - uses: amannn/action-semantic-pull-request@v5.5.3 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.golangci.yaml b/.golangci.yaml index 6166d2e..fb98ac3 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -30,6 +30,7 @@ linters: - misspell - musttag - nilerr + - nilnesserr - nolintlint - paralleltest - perfsprint diff --git a/README.md b/README.md index 3b2ae12..9e52605 100644 --- a/README.md +++ b/README.md @@ -22,35 +22,14 @@

- - - - OpenAPI Support - - Arazzo Support - Go Doc -
- + Release - + Go Doc +
+ GitHub Action: Test Go Report Card - - Security - - GitHub Action: CI -
- - Go Version - - Platform Support - - GitHub stars -
- - - - Software License + Software License

diff --git a/cmd/update-examples/main.go b/cmd/update-examples/main.go index 3d1b5ad..b5ccee0 100644 --- a/cmd/update-examples/main.go +++ b/cmd/update-examples/main.go @@ -38,8 +38,8 @@ func main() { func updateExamples() error { fmt.Println("🔄 Updating examples in README files...") - // Process both packages - packages := []string{"openapi", "arazzo"} + // Process all packages + packages := []string{"openapi", "arazzo", "overlay"} for _, pkg := range packages { if err := processPackage(pkg); err != nil { diff --git a/go.mod b/go.mod index 949c8d0..d514e02 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,9 @@ go 1.24.3 require ( github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 github.com/speakeasy-api/jsonpath v0.6.2 + github.com/spf13/cobra v1.7.0 github.com/stretchr/testify v1.10.0 + github.com/vmware-labs/yaml-jsonpath v0.3.2 golang.org/x/sync v0.16.0 golang.org/x/text v0.28.0 gopkg.in/yaml.v3 v3.0.1 @@ -13,6 +15,9 @@ require ( require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kr/text v0.2.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/spf13/pflag v1.0.5 // indirect ) diff --git a/go.sum b/go.sum index 1bf3b8b..2f3c538 100644 --- a/go.sum +++ b/go.sum @@ -1,26 +1,73 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960 h1:aRd8M7HJVZOqn/vhOzrGcQH0lNAMkqMn+pXUYkatmcA= +github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.10.2 h1:uqH7bpe+ERSiDa34FDOF7RikN6RzXgduUF8yarlZp94= +github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v1.7.0 h1:XPnZz8VVBHjVsy1vzJmRwIcSwiUO+JFfrv/xGiigmME= +github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= +github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/speakeasy-api/jsonpath v0.6.2 h1:Mys71yd6u8kuowNCR0gCVPlVAHCmKtoGXYoAtcEbqXQ= github.com/speakeasy-api/jsonpath v0.6.2/go.mod h1:ymb2iSkyOycmzKwbEAYPJV/yi2rSmvBCLZJcyD+VVWw= +github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= +github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk= +github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd h1:nTDtHvHSdCn1m6ITfMRqtOd/9+7a3s8RBNOZ3eYZzJA= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e h1:o3PsSEY8E4eXWkXrIP9YJALUkVZqzHJT5DOasTyn8Vs= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/overlay/README.md b/overlay/README.md new file mode 100644 index 0000000..e1af147 --- /dev/null +++ b/overlay/README.md @@ -0,0 +1,294 @@ +

+

+ OpenAPI +

+

OpenAPI Overlay

+

An implementation of the OpenAPI Overlay Specification for applying modifications to OpenAPI documents +

+

+ + Release + Go Doc +
+ GitHub Action: Test + Go Report Card + Software License +
+ +

+

+ +> ⚠️ This an alpha implementation. If you'd like to discuss a production use case please join the Speakeasy [slack](https://join.slack.com/t/speakeasy-dev/shared_invite/zt-1df0lalk5-HCAlpcQiqPw8vGukQWhexw). + +## Features + +- **OpenAPI Overlay Specification Compliance**: Full implementation of the [OpenAPI Overlay Specification](https://github.com/OAI/Overlay-Specification/blob/3f398c6/versions/1.0.0.md) (2023-10-12) +- **JSONPath Target Selection**: Uses JSONPath expressions to select nodes for modification +- **Remove and Update Actions**: Support for both remove actions (pruning nodes) and update actions (merging values) +- **Flexible Input/Output**: Works with both YAML and JSON formats +- **Batch Operations**: Apply multiple modifications to large numbers of nodes in a single operation +- **YAML v1.2 Support**: Uses [gopkg.in/yaml.v3](https://pkg.go.dev/gopkg.in/yaml.v3) for YAML v1.2 parsing (superset of JSON) + +## About OpenAPI Overlays + +This specification defines a means of editing an OpenAPI Specification file by applying a list of actions. Each action is either a remove action that prunes nodes or an update that merges a value into nodes. The nodes impacted are selected by a target expression which uses JSONPath. + +The specification itself says very little about the input file to be modified or the output file. The presumed intention is that the input and output be an OpenAPI Specification, but that is not required. + +In many ways, this is similar to [JSONPatch](https://jsonpatch.com/), but without the requirement to use a single explicit path for each operation. This allows the creator of an overlay file to apply a single modification to a large number of nodes in the file within a single operation. + + + +## Apply an overlay to an OpenAPI document + +Shows loading an overlay specification and applying it to transform an OpenAPI document. + +```go +overlayContent := `overlay: 1.0.0 +info: + title: Pet Store Enhancement Overlay + version: 1.0.0 +actions: + - target: $.info.description + update: Enhanced pet store API with additional features` + +openAPIContent := `openapi: 3.1.0 +info: + title: Pet Store API + version: 1.0.0 + description: A simple pet store API +paths: + /pets: + get: + summary: List pets + responses: + '200': + description: A list of pets` + +overlayFile := "temp_overlay.yaml" +openAPIFile := "temp_openapi.yaml" +if err := os.WriteFile(overlayFile, []byte(overlayContent), 0644); err != nil { + panic(err) +} +if err := os.WriteFile(openAPIFile, []byte(openAPIContent), 0644); err != nil { + panic(err) +} +defer os.Remove(overlayFile) +defer os.Remove(openAPIFile) + +overlayDoc, err := overlay.Parse(overlayFile) +if err != nil { + panic(err) +} + +openAPINode, err := loader.LoadSpecification(openAPIFile) +if err != nil { + panic(err) +} + +err = overlayDoc.ApplyTo(openAPINode) +if err != nil { + panic(err) +} + +// Convert back to YAML string +var buf strings.Builder +encoder := yaml.NewEncoder(&buf) +encoder.SetIndent(2) +err = encoder.Encode(openAPINode) +if err != nil { + panic(err) +} + +fmt.Printf("Transformed document:\n%s", buf.String()) +``` + +## Create an overlay specification programmatically + +Shows building an overlay specification with update and remove actions. + +```go +// Create update value as yaml.Node +var updateNode yaml.Node +updateNode.SetString("Enhanced API with additional features") + +overlayDoc := &overlay.Overlay{ + Version: "1.0.0", + Info: overlay.Info{ + Title: "API Enhancement Overlay", + Version: "1.0.0", + }, + Actions: []overlay.Action{ + { + Target: "$.info.description", + Update: updateNode, + }, + { + Target: "$.paths['/deprecated-endpoint']", + Remove: true, + }, + }, +} + +result, err := overlayDoc.ToString() +if err != nil { + panic(err) +} + +fmt.Printf("Overlay specification:\n%s", result) +``` + +## Parse an overlay specification from a file + +Shows loading an overlay file and accessing its properties. + +```go +overlayContent := `overlay: 1.0.0 +info: + title: API Modification Overlay + version: 1.0.0 +actions: + - target: $.info.title + update: Enhanced Pet Store API + - target: $.info.version + update: 2.0.0` + +overlayFile := "temp_overlay.yaml" +if err := os.WriteFile(overlayFile, []byte(overlayContent), 0644); err != nil { + panic(err) +} +defer func() { _ = os.Remove(overlayFile) }() + +overlayDoc, err := overlay.Parse(overlayFile) +if err != nil { + panic(err) +} + +fmt.Printf("Overlay Version: %s\n", overlayDoc.Version) +fmt.Printf("Title: %s\n", overlayDoc.Info.Title) +fmt.Printf("Number of Actions: %d\n", len(overlayDoc.Actions)) + +for i, action := range overlayDoc.Actions { + fmt.Printf("Action %d Target: %s\n", i+1, action.Target) +} +``` + +## Validate an overlay specification + +Shows loading and validating an overlay specification for correctness. + +```go +invalidOverlay := `overlay: 1.0.0 +info: + title: Invalid Overlay +actions: + - target: $.info.title + description: Missing update or remove` + +overlayFile := "temp_invalid_overlay.yaml" +if err := os.WriteFile(overlayFile, []byte(invalidOverlay), 0644); err != nil { + panic(err) +} +defer func() { _ = os.Remove(overlayFile) }() + +overlayDoc, err := overlay.Parse(overlayFile) +if err != nil { + fmt.Printf("Parse error: %s\n", err.Error()) + return +} + +validationErr := overlayDoc.Validate() +if validationErr != nil { + fmt.Println("Validation errors:") + fmt.Printf(" %s\n", validationErr.Error()) +} else { + fmt.Println("Overlay specification is valid!") +} +``` + +## Use remove actions in overlays + +Shows removing specific paths and properties from an OpenAPI document. + +```go +openAPIContent := `openapi: 3.1.0 +info: + title: API + version: 1.0.0 +paths: + /users: + get: + summary: List users + /users/{id}: + get: + summary: Get user + /admin: + get: + summary: Admin endpoint + deprecated: true` + +overlayContent := `overlay: 1.0.0 +info: + title: Cleanup Overlay + version: 1.0.0 +actions: + - target: $.paths['/admin'] + remove: true` + +openAPIFile := "temp_openapi.yaml" +overlayFile := "temp_overlay.yaml" +if err := os.WriteFile(openAPIFile, []byte(openAPIContent), 0644); err != nil { + panic(err) +} +if err := os.WriteFile(overlayFile, []byte(overlayContent), 0644); err != nil { + panic(err) +} +defer func() { _ = os.Remove(openAPIFile) }() +defer func() { _ = os.Remove(overlayFile) }() + +overlayDoc, err := overlay.Parse(overlayFile) +if err != nil { + panic(err) +} + +openAPINode, err := loader.LoadSpecification(openAPIFile) +if err != nil { + panic(err) +} + +err = overlayDoc.ApplyTo(openAPINode) +if err != nil { + panic(err) +} + +var buf strings.Builder +encoder := yaml.NewEncoder(&buf) +encoder.SetIndent(2) +err = encoder.Encode(openAPINode) +if err != nil { + panic(err) +} + +fmt.Printf("Document after removing deprecated endpoint:\n%s", buf.String()) +``` + + + +## Contributing + +This repository is maintained by Speakeasy, but we welcome and encourage contributions from the community to help improve its capabilities and stability. + +### How to Contribute + +1. **Open Issues**: Found a bug or have a feature suggestion? Open an issue to describe what you'd like to see changed. + +2. **Pull Requests**: We welcome pull requests! If you'd like to contribute code: + - Fork the repository + - Create a new branch for your feature/fix + - Submit a PR with a clear description of the changes and any related issues + +3. **Feedback**: Share your experience using the packages or suggest improvements. + +All contributions, whether they're bug reports, feature requests, or code changes, help make this project better for everyone. + +Please ensure your contributions adhere to our coding standards and include appropriate tests where applicable. diff --git a/overlay/apply.go b/overlay/apply.go new file mode 100644 index 0000000..3dc5353 --- /dev/null +++ b/overlay/apply.go @@ -0,0 +1,238 @@ +package overlay + +import ( + "fmt" + "strings" + + "github.com/speakeasy-api/jsonpath/pkg/jsonpath/config" + "github.com/speakeasy-api/jsonpath/pkg/jsonpath/token" + "gopkg.in/yaml.v3" +) + +// ApplyTo will take an overlay and apply its changes to the given YAML +// document. +func (o *Overlay) ApplyTo(root *yaml.Node) error { + for _, action := range o.Actions { + var err error + if action.Remove { + err = o.applyRemoveAction(root, action, nil) + } else { + err = o.applyUpdateAction(root, action, &[]string{}) + } + + if err != nil { + return err + } + } + + return nil +} + +func (o *Overlay) ApplyToStrict(root *yaml.Node) ([]string, error) { + multiError := []string{} + warnings := []string{} + hasFilterExpression := false + for i, action := range o.Actions { + tokens := token.NewTokenizer(action.Target, config.WithPropertyNameExtension()).Tokenize() + for _, tok := range tokens { + if tok.Token == token.FILTER { + hasFilterExpression = true + } + } + + actionWarnings := []string{} + err := o.validateSelectorHasAtLeastOneTarget(root, action) + if err != nil { + multiError = append(multiError, err.Error()) + } + if action.Remove { + err = o.applyRemoveAction(root, action, &actionWarnings) + if err != nil { + multiError = append(multiError, err.Error()) + } + } else { + err = o.applyUpdateAction(root, action, &actionWarnings) + if err != nil { + multiError = append(multiError, err.Error()) + } + } + for _, warning := range actionWarnings { + warnings = append(warnings, fmt.Sprintf("update action (%v / %v) target=%s: %s", i+1, len(o.Actions), action.Target, warning)) + } + } + + if hasFilterExpression && !o.UsesRFC9535() { + warnings = append(warnings, "overlay has a filter expression but lacks `x-speakeasy-jsonpath: rfc9535` extension. Deprecated jsonpath behaviour in use. See overlay.speakeasy.com for the implementation playground.") + } + + if len(multiError) > 0 { + return warnings, fmt.Errorf("error applying overlay (strict): %v", strings.Join(multiError, ",")) + } + return warnings, nil +} + +func (o *Overlay) validateSelectorHasAtLeastOneTarget(root *yaml.Node, action Action) error { + if action.Target == "" { + return nil + } + + p, err := o.NewPath(action.Target, nil) + if err != nil { + return err + } + + nodes := p.Query(root) + + if len(nodes) == 0 { + return fmt.Errorf("selector %q did not match any targets", action.Target) + } + + return nil +} + +func (o *Overlay) applyRemoveAction(root *yaml.Node, action Action, warnings *[]string) error { + if action.Target == "" { + return nil + } + + idx := newParentIndex(root) + + p, err := o.NewPath(action.Target, warnings) + if err != nil { + return err + } + + nodes := p.Query(root) + + for _, node := range nodes { + removeNode(idx, node) + } + + return nil +} + +func removeNode(idx parentIndex, node *yaml.Node) { + parent := idx.getParent(node) + if parent == nil { + return + } + + for i, child := range parent.Content { + if child == node { + switch parent.Kind { + case yaml.MappingNode: + if i%2 == 1 { + // if we select a value, we should delete the key too + parent.Content = append(parent.Content[:i-1], parent.Content[i+1:]...) + } else { + // if we select a key, we should delete the value + parent.Content = append(parent.Content[:i], parent.Content[i+2:]...) + } + return + case yaml.SequenceNode: + parent.Content = append(parent.Content[:i], parent.Content[i+1:]...) + return + } + } + } +} + +func (o *Overlay) applyUpdateAction(root *yaml.Node, action Action, warnings *[]string) error { + if action.Target == "" { + return nil + } + + if action.Update.IsZero() { + return nil + } + + p, err := o.NewPath(action.Target, warnings) + if err != nil { + return err + } + + nodes := p.Query(root) + + didMakeChange := false + for _, node := range nodes { + didMakeChange = updateNode(node, &action.Update) || didMakeChange + } + if !didMakeChange { + *warnings = append(*warnings, "does nothing") + } + + return nil +} + +func updateNode(node *yaml.Node, updateNode *yaml.Node) bool { + return mergeNode(node, updateNode) +} + +func mergeNode(node *yaml.Node, merge *yaml.Node) bool { + if node.Kind != merge.Kind { + *node = *clone(merge) + return true + } + switch node.Kind { + default: + isChanged := node.Value != merge.Value + node.Value = merge.Value + return isChanged + case yaml.MappingNode: + return mergeMappingNode(node, merge) + case yaml.SequenceNode: + return mergeSequenceNode(node, merge) + } +} + +// mergeMappingNode will perform a shallow merge of the merge node into the main +// node. +func mergeMappingNode(node *yaml.Node, merge *yaml.Node) bool { + anyChange := false +NextKey: + for i := 0; i < len(merge.Content); i += 2 { + mergeKey := merge.Content[i].Value + mergeValue := merge.Content[i+1] + + for j := 0; j < len(node.Content); j += 2 { + nodeKey := node.Content[j].Value + if nodeKey == mergeKey { + anyChange = mergeNode(node.Content[j+1], mergeValue) || anyChange + continue NextKey + } + } + + node.Content = append(node.Content, merge.Content[i], clone(mergeValue)) + anyChange = true + } + return anyChange +} + +// mergeSequenceNode will append the merge node's content to the original node. +func mergeSequenceNode(node *yaml.Node, merge *yaml.Node) bool { + node.Content = append(node.Content, clone(merge).Content...) + return true +} + +func clone(node *yaml.Node) *yaml.Node { + newNode := &yaml.Node{ + Kind: node.Kind, + Style: node.Style, + Tag: node.Tag, + Value: node.Value, + Anchor: node.Anchor, + HeadComment: node.HeadComment, + LineComment: node.LineComment, + FootComment: node.FootComment, + } + if node.Alias != nil { + newNode.Alias = clone(node.Alias) + } + if node.Content != nil { + newNode.Content = make([]*yaml.Node, len(node.Content)) + for i, child := range node.Content { + newNode.Content[i] = clone(child) + } + } + return newNode +} diff --git a/overlay/apply_test.go b/overlay/apply_test.go new file mode 100644 index 0000000..aa9b086 --- /dev/null +++ b/overlay/apply_test.go @@ -0,0 +1,310 @@ +package overlay_test + +import ( + "bytes" + "os" + "strconv" + "testing" + + "github.com/speakeasy-api/jsonpath/pkg/jsonpath" + "github.com/speakeasy-api/openapi/overlay" + "github.com/speakeasy-api/openapi/overlay/loader" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +// NodeMatchesFile is a test that marshals the YAML file from the given node, +// then compares those bytes to those found in the expected file. +func NodeMatchesFile( + t *testing.T, + actual *yaml.Node, + expectedFile string, + msgAndArgs ...any, +) { + t.Helper() + variadoc := func(pre ...any) []any { return append(msgAndArgs, pre...) } + + var actualBuf bytes.Buffer + enc := yaml.NewEncoder(&actualBuf) + enc.SetIndent(2) + err := enc.Encode(actual) + require.NoError(t, err, variadoc("failed to marshal node: ")...) + + expectedBytes, err := os.ReadFile(expectedFile) + require.NoError(t, err, variadoc("failed to read expected file: ")...) + + // lazy redo snapshot + // os.WriteFile(expectedFile, actualBuf.Bytes(), 0644) + + // t.Log("### EXPECT START ###\n" + string(expectedBytes) + "\n### EXPECT END ###\n") + // t.Log("### ACTUAL START ###\n" + actualBuf.string() + "\n### ACTUAL END ###\n") + + assert.Equal(t, string(expectedBytes), actualBuf.String(), variadoc("node does not match expected file: ")...) +} + +func TestApplyTo(t *testing.T) { + t.Parallel() + + node, err := loader.LoadSpecification("testdata/openapi.yaml") + require.NoError(t, err) + + o, err := loader.LoadOverlay("testdata/overlay.yaml") + require.NoError(t, err) + + err = o.ApplyTo(node) + require.NoError(t, err) + + NodeMatchesFile(t, node, "testdata/openapi-overlayed.yaml") +} + +func TestApplyToStrict(t *testing.T) { + t.Parallel() + + node, err := loader.LoadSpecification("testdata/openapi.yaml") + require.NoError(t, err) + + o, err := loader.LoadOverlay("testdata/overlay-mismatched.yaml") + require.NoError(t, err) + + warnings, err := o.ApplyToStrict(node) + require.Error(t, err, "error applying overlay (strict): selector \"$.unknown-attribute\" did not match any targets") + assert.Len(t, warnings, 2) + o.Actions = o.Actions[1:] + node, err = loader.LoadSpecification("testdata/openapi.yaml") + require.NoError(t, err) + + warnings, err = o.ApplyToStrict(node) + require.NoError(t, err) + assert.Len(t, warnings, 1) + assert.Equal(t, "update action (2 / 2) target=$.info.title: does nothing", warnings[0]) + NodeMatchesFile(t, node, "testdata/openapi-strict-onechange.yaml") + + node, err = loader.LoadSpecification("testdata/openapi.yaml") + require.NoError(t, err) + + o, err = loader.LoadOverlay("testdata/overlay.yaml") + require.NoError(t, err) + + err = o.ApplyTo(node) + require.NoError(t, err) + + NodeMatchesFile(t, node, "testdata/openapi-overlayed.yaml") + +} + +func BenchmarkApplyToStrict(b *testing.B) { + openAPIBytes, err := os.ReadFile("testdata/openapi.yaml") + require.NoError(b, err) + overlayBytes, err := os.ReadFile("testdata/overlay-zero-change.yaml") + require.NoError(b, err) + + var specNode yaml.Node + err = yaml.NewDecoder(bytes.NewReader(openAPIBytes)).Decode(&specNode) + require.NoError(b, err) + + // Load overlay from bytes + var o overlay.Overlay + err = yaml.NewDecoder(bytes.NewReader(overlayBytes)).Decode(&o) + require.NoError(b, err) + + // Apply overlay to spec + for b.Loop() { + _, _ = o.ApplyToStrict(&specNode) + } +} + +func BenchmarkApplyToStrictBySize(b *testing.B) { + // Read the base OpenAPI spec + openAPIBytes, err := os.ReadFile("testdata/openapi.yaml") + require.NoError(b, err) + + // Read the overlay spec + overlayBytes, err := os.ReadFile("testdata/overlay-zero-change.yaml") + require.NoError(b, err) + + // Decode the base spec + var baseSpec yaml.Node + err = yaml.NewDecoder(bytes.NewReader(openAPIBytes)).Decode(&baseSpec) + require.NoError(b, err) + + // Find the paths node and a path to duplicate + pathsNode := findPathsNode(&baseSpec) + require.NotNil(b, pathsNode) + + // Get the first path item to use as template + var templatePath *yaml.Node + var templateKey string + for i := 0; i < len(pathsNode.Content); i += 2 { + if pathsNode.Content[i].Kind == yaml.ScalarNode && pathsNode.Content[i].Value[0] == '/' { + templateKey = pathsNode.Content[i].Value + templatePath = pathsNode.Content[i+1] + break + } + } + require.NotNil(b, templatePath) + + // Target sizes: 2KB, 20KB, 200KB, 2MB, 20MB + targetSizes := []struct { + size int + name string + }{ + {2 * 1024, "2KB"}, + {20 * 1024, "20KB"}, + {200 * 1024, "200KB"}, + {2000 * 1024, "2M"}, + } + + // Calculate the base document size + var baseBuf bytes.Buffer + enc := yaml.NewEncoder(&baseBuf) + err = enc.Encode(&baseSpec) + require.NoError(b, err) + baseSize := baseBuf.Len() + + // Calculate the size of a single path item by encoding it + var pathBuf bytes.Buffer + pathEnc := yaml.NewEncoder(&pathBuf) + tempNode := &yaml.Node{ + Kind: yaml.MappingNode, + Content: []*yaml.Node{ + {Kind: yaml.ScalarNode, Value: templateKey + "-test"}, + cloneNode(templatePath), + }, + } + err = pathEnc.Encode(tempNode) + require.NoError(b, err) + // Approximate size contribution of one path (accounting for YAML structure) + pathItemSize := pathBuf.Len() - 10 // Subtract some overhead + + for _, target := range targetSizes { + b.Run(target.name, func(b *testing.B) { + // Create a copy of the base spec + specCopy := cloneNode(&baseSpec) + pathsNodeCopy := findPathsNode(specCopy) + + // Calculate how many paths we need to add + bytesNeeded := target.size - baseSize + pathsToAdd := 0 + if bytesNeeded > 0 { + pathsToAdd = bytesNeeded / pathItemSize + // Add a few extra to ensure we exceed the target + pathsToAdd += 5 + } + + // Add the calculated number of path duplicates + for i := 0; i < pathsToAdd; i++ { + newPathKey := yaml.Node{Kind: yaml.ScalarNode, Value: templateKey + "-duplicate-" + strconv.Itoa(i)} + newPathValue := cloneNode(templatePath) + pathsNodeCopy.Content = append(pathsNodeCopy.Content, &newPathKey, newPathValue) + } + + // Verify final size + var finalBuf bytes.Buffer + finalEnc := yaml.NewEncoder(&finalBuf) + err = finalEnc.Encode(specCopy) + require.NoError(b, err) + actualSize := finalBuf.Len() + b.Logf("OpenAPI size: %d bytes (target: %d, paths added: %d)", actualSize, target.size, pathsToAdd) + + // Load overlay + var o overlay.Overlay + err = yaml.NewDecoder(bytes.NewReader(overlayBytes)).Decode(&o) + require.NoError(b, err) + + specForTest := cloneNode(specCopy) + // Run the benchmark + b.ResetTimer() + for b.Loop() { + _, _ = o.ApplyToStrict(specForTest) + } + }) + } +} + +// Helper function to find the paths node in the OpenAPI spec +func findPathsNode(node *yaml.Node) *yaml.Node { + if node.Kind == yaml.DocumentNode && len(node.Content) > 0 { + node = node.Content[0] + } + + if node.Kind != yaml.MappingNode { + return nil + } + + for i := 0; i < len(node.Content); i += 2 { + if node.Content[i].Value == "paths" { + return node.Content[i+1] + } + } + return nil +} + +// Helper function to deep clone a YAML node +func cloneNode(node *yaml.Node) *yaml.Node { + if node == nil { + return nil + } + + clone := &yaml.Node{ + Kind: node.Kind, + Style: node.Style, + Tag: node.Tag, + Value: node.Value, + Anchor: node.Anchor, + Alias: node.Alias, + HeadComment: node.HeadComment, + LineComment: node.LineComment, + FootComment: node.FootComment, + Line: node.Line, + Column: node.Column, + } + + if node.Content != nil { + clone.Content = make([]*yaml.Node, len(node.Content)) + for i, child := range node.Content { + clone.Content[i] = cloneNode(child) + } + } + + return clone +} + +func TestApplyToOld(t *testing.T) { + t.Parallel() + + nodeOld, err := loader.LoadSpecification("testdata/openapi.yaml") + require.NoError(t, err) + + nodeNew, err := loader.LoadSpecification("testdata/openapi.yaml") + require.NoError(t, err) + + o, err := loader.LoadOverlay("testdata/overlay-old.yaml") + require.NoError(t, err) + + warnings, err := o.ApplyToStrict(nodeOld) + require.NoError(t, err) + require.Len(t, warnings, 2) + require.Contains(t, warnings[0], "invalid rfc9535 jsonpath") + require.Contains(t, warnings[1], "x-speakeasy-jsonpath: rfc9535") + + path, err := jsonpath.NewPath(`$.paths["/anything/selectGlobalServer"]`) + require.NoError(t, err) + result := path.Query(nodeOld) + require.NoError(t, err) + require.Empty(t, result) + o.JSONPathVersion = "rfc9535" + _, err = o.ApplyToStrict(nodeNew) + require.ErrorContains(t, err, "unexpected token") // should error out: invalid nodepath + // now lets fix it. + o.Actions[0].Target = "$.paths.*[?(@[\"x-my-ignore\"])]" + _, err = o.ApplyToStrict(nodeNew) + require.ErrorContains(t, err, "did not match any targets") + // Now lets fix it. + o.Actions[0].Target = "$.paths[?(@[\"x-my-ignore\"])]" // @ should always refer to the child node in RFC 9535.. + _, err = o.ApplyToStrict(nodeNew) + require.NoError(t, err) + result = path.Query(nodeNew) + require.NoError(t, err) + require.Empty(t, result) +} diff --git a/overlay/cmd/README.md b/overlay/cmd/README.md new file mode 100644 index 0000000..dd7f60c --- /dev/null +++ b/overlay/cmd/README.md @@ -0,0 +1,51 @@ +# TEMPORARY README FOR COMMANDS UNTIL CLI IS REINSTATED + +# Installation + +Install it with the `go install` command: + +```sh +go install github.com/speakeasy-api/openapi-overlay@latest +``` + +# Usage + +The tool provides sub-commands such as `apply`, `validate` and `compare` under the `openapi-overlay` command for working with overlay files. + +The recommended usage pattern is through Speakeasy CLI command `speakeasy overlay`. Please see [here](https://www.speakeasyapi.dev/docs/speakeasy-cli/overlay/README) for CLI installation and usage documentation. + +However, the `openapi-overlay` tool can be used standalone. + +For more examples of usage, see [here](https://www.speakeasyapi.dev/docs/openapi/overlays) + +## Apply + +The most obvious use-case for this command is applying an overlay to a specification file. + +```sh +openapi-overlay apply --overlay=overlay.yaml --schema=spec.yaml +``` + +If the overlay file has the `extends` key set to a `file://` URL, then the `spec.yaml` file may be omitted. + +## Validate + +A command is provided to perform basic validation of the overlay file itself. It will not tell you whether it will apply correctly or whether the application will generate a valid OpenAPI specification. Rather, it is limited to just telling you when the spec follows the OpenAPI Overlay Specification correctly: all required fields are present and have valid values. + +```sh +openapi-overlay validate --overlay=overlay.yaml +``` + +## Compare + +Finally, a tool is provided that will generate an OpenAPI Overlay specification from two input files. + +```sh +openapi-overlay compare --before=spec1.yaml --after=spec2.yaml --out=overlay.yaml +``` + +the overlay file will be written to a file called `overlay.yaml` with a diagnostic output in the console. + +# Other Notes + +This tool works with either YAML or JSON input files, but always outputs YAML at this time. \ No newline at end of file diff --git a/overlay/cmd/apply.go b/overlay/cmd/apply.go new file mode 100644 index 0000000..1ed16fa --- /dev/null +++ b/overlay/cmd/apply.go @@ -0,0 +1,46 @@ +package cmd + +import ( + "os" + + "github.com/speakeasy-api/openapi/overlay/loader" + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" +) + +var ( + applyCmd = &cobra.Command{ + Use: "apply [ ]", + Short: "Given an overlay, it will apply it to the spec. If omitted, spec will be loaded via extends (only from local file system).", + Args: cobra.RangeArgs(1, 2), + Run: RunApply, + } +) + +func RunApply(cmd *cobra.Command, args []string) { + overlayFile := args[0] + + o, err := loader.LoadOverlay(overlayFile) + if err != nil { + Die(err) + } + + var specFile string + if len(args) > 1 { + specFile = args[1] + } + ys, specFile, err := loader.LoadEitherSpecification(specFile, o) + if err != nil { + Die(err) + } + + err = o.ApplyTo(ys) + if err != nil { + Dief("Failed to apply overlay to spec file %q: %v", specFile, err) + } + + err = yaml.NewEncoder(os.Stdout).Encode(ys) + if err != nil { + Dief("Failed to encode spec file %q: %v", specFile, err) + } +} diff --git a/overlay/cmd/compare.go b/overlay/cmd/compare.go new file mode 100644 index 0000000..679234d --- /dev/null +++ b/overlay/cmd/compare.go @@ -0,0 +1,43 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/speakeasy-api/openapi/overlay" + "github.com/speakeasy-api/openapi/overlay/loader" + "github.com/spf13/cobra" +) + +var ( + compareCmd = &cobra.Command{ + Use: "compare ", + Short: "Given two specs, it will output an overlay that describes the differences between them", + Args: cobra.ExactArgs(2), + Run: RunCompare, + } +) + +func RunCompare(cmd *cobra.Command, args []string) { + y1, err := loader.LoadSpecification(args[0]) + if err != nil { + Dief("Failed to load %q: %v", args[0], err) + } + + y2, err := loader.LoadSpecification(args[1]) + if err != nil { + Dief("Failed to load %q: %v", args[1], err) + } + + title := fmt.Sprintf("Overlay %s => %s", args[0], args[1]) + + o, err := overlay.Compare(title, y1, *y2) + if err != nil { + Dief("Failed to compare spec files %q and %q: %v", args[0], args[1], err) + } + + err = o.Format(os.Stdout) + if err != nil { + Dief("Failed to format overlay: %v", err) + } +} diff --git a/overlay/cmd/root.go b/overlay/cmd/root.go new file mode 100644 index 0000000..9892c78 --- /dev/null +++ b/overlay/cmd/root.go @@ -0,0 +1,21 @@ +package cmd + +import "github.com/spf13/cobra" + +var ( + rootCmd = &cobra.Command{ + Use: "openapi-overlay", + Short: "Work with OpenAPI Overlays", + } +) + +func init() { + rootCmd.AddCommand(applyCmd) + rootCmd.AddCommand(compareCmd) + rootCmd.AddCommand(validateCmd) +} + +func Execute() { + err := rootCmd.Execute() + cobra.CheckErr(err) +} diff --git a/overlay/cmd/shared.go b/overlay/cmd/shared.go new file mode 100644 index 0000000..ac7d72b --- /dev/null +++ b/overlay/cmd/shared.go @@ -0,0 +1,16 @@ +package cmd + +import ( + "fmt" + "os" +) + +func Dief(f string, args ...any) { + fmt.Fprintf(os.Stderr, f+"\n", args...) + os.Exit(1) +} + +func Die(err error) { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) +} diff --git a/overlay/cmd/validate.go b/overlay/cmd/validate.go new file mode 100644 index 0000000..6ec1fda --- /dev/null +++ b/overlay/cmd/validate.go @@ -0,0 +1,31 @@ +package cmd + +import ( + "fmt" + + "github.com/speakeasy-api/openapi/overlay/loader" + "github.com/spf13/cobra" +) + +var ( + validateCmd = &cobra.Command{ + Use: "validate ", + Short: "Given an overlay, it will state whether it appears to be valid or describe the problems found", + Args: cobra.ExactArgs(1), + Run: RunValidateOverlay, + } +) + +func RunValidateOverlay(cmd *cobra.Command, args []string) { + o, err := loader.LoadOverlay(args[0]) + if err != nil { + Die(err) + } + + err = o.Validate() + if err != nil { + Dief("Overlay file %q failed validation:\n%v", args[0], err) + } + + fmt.Printf("Overlay file %q is valid.\n", args[0]) +} diff --git a/overlay/compare.go b/overlay/compare.go new file mode 100644 index 0000000..de03a23 --- /dev/null +++ b/overlay/compare.go @@ -0,0 +1,265 @@ +package overlay + +import ( + "bytes" + "fmt" + "log" + "strings" + + "gopkg.in/yaml.v3" +) + +// Compare compares input specifications from two files and returns an overlay +// that will convert the first into the second. +func Compare(title string, y1 *yaml.Node, y2 yaml.Node) (*Overlay, error) { + actions, err := walkTreesAndCollectActions(simplePath{}, y1, y2) + if err != nil { + return nil, err + } + + return &Overlay{ + Version: "1.0.0", + JSONPathVersion: "rfc9535", + Info: Info{ + Title: title, + Version: "0.0.0", + }, + Actions: actions, + }, nil +} + +type simplePart struct { + isKey bool + key string + index int +} + +func intPart(index int) simplePart { + return simplePart{ + index: index, + } +} + +func keyPart(key string) simplePart { + return simplePart{ + isKey: true, + key: key, + } +} + +func (p simplePart) String() string { + if p.isKey { + return fmt.Sprintf("[%q]", p.key) + } + return fmt.Sprintf("[%d]", p.index) +} + +func (p simplePart) KeyString() string { + if p.isKey { + return p.key + } + panic("FIXME: Bug detected in overlay comparison algorithm: attempt to use non key part as key") +} + +type simplePath []simplePart + +func (p simplePath) WithIndex(index int) simplePath { + return append(p, intPart(index)) +} + +func (p simplePath) WithKey(key string) simplePath { + return append(p, keyPart(key)) +} + +func (p simplePath) ToJSONPath() string { + out := &strings.Builder{} + out.WriteString("$") + for _, part := range p { + out.WriteString(part.String()) + } + return out.String() +} + +func (p simplePath) Dir() simplePath { + return p[:len(p)-1] +} + +func (p simplePath) Base() simplePart { + return p[len(p)-1] +} + +func walkTreesAndCollectActions(path simplePath, y1 *yaml.Node, y2 yaml.Node) ([]Action, error) { + if y1 == nil { + return []Action{{ + Target: path.Dir().ToJSONPath(), + Update: y2, + }}, nil + } + + if y2.IsZero() { + return []Action{{ + Target: path.ToJSONPath(), + Remove: true, + }}, nil + } + if y1.Kind != y2.Kind { + return []Action{{ + Target: path.ToJSONPath(), + Update: y2, + }}, nil + } + + switch y1.Kind { + case yaml.DocumentNode: + return walkTreesAndCollectActions(path, y1.Content[0], *y2.Content[0]) + case yaml.SequenceNode: + if len(y2.Content) == len(y1.Content) { + return walkSequenceNode(path, y1, y2) + } + + if len(y2.Content) == len(y1.Content)+1 && + yamlEquals(y2.Content[:len(y1.Content)], y1.Content) { + return []Action{{ + Target: path.ToJSONPath(), + Update: yaml.Node{ + Kind: y1.Kind, + Content: []*yaml.Node{y2.Content[len(y1.Content)]}, + }, + }}, nil + } + + return []Action{{ + Target: path.ToJSONPath() + "[*]", // target all elements + Remove: true, + }, { + Target: path.ToJSONPath(), + Update: yaml.Node{ + Kind: y1.Kind, + Content: y2.Content, + }, + }}, nil + case yaml.MappingNode: + return walkMappingNode(path, y1, y2) + case yaml.ScalarNode: + if y1.Value != y2.Value { + return []Action{{ + Target: path.ToJSONPath(), + Update: y2, + }}, nil + } + case yaml.AliasNode: + log.Println("YAML alias nodes are not yet supported for compare.") + } + return nil, nil +} + +func yamlEquals(nodes []*yaml.Node, content []*yaml.Node) bool { + for i := range nodes { + bufA := &bytes.Buffer{} + bufB := &bytes.Buffer{} + decodeA := yaml.NewEncoder(bufA) + decodeB := yaml.NewEncoder(bufB) + err := decodeA.Encode(nodes[i]) + if err != nil { + return false + } + err = decodeB.Encode(content[i]) + if err != nil { + return false + } + + if bufA.String() != bufB.String() { + return false + } + } + return true +} + +func walkSequenceNode(path simplePath, y1 *yaml.Node, y2 yaml.Node) ([]Action, error) { + nodeLen := max(len(y1.Content), len(y2.Content)) + var actions []Action + for i := 0; i < nodeLen; i++ { + var c1, c2 *yaml.Node + if i < len(y1.Content) { + c1 = y1.Content[i] + } + if i < len(y2.Content) { + c2 = y2.Content[i] + } + + var y2Val yaml.Node + if c2 != nil { + y2Val = *c2 + } + newActions, err := walkTreesAndCollectActions( + path.WithIndex(i), + c1, y2Val) + if err != nil { + return nil, err + } + + actions = append(actions, newActions...) + } + + return actions, nil +} + +func walkMappingNode(path simplePath, y1 *yaml.Node, y2 yaml.Node) ([]Action, error) { + var actions []Action + foundKeys := map[string]struct{}{} + + // Add or update keys in y2 that differ/missing from y1 +Outer: + for i := 0; i < len(y2.Content); i += 2 { + k2 := y2.Content[i] + v2 := y2.Content[i+1] + + foundKeys[k2.Value] = struct{}{} + + // find keys in y1 to update + for j := 0; j < len(y1.Content); j += 2 { + k1 := y1.Content[j] + v1 := y1.Content[j+1] + + if k1.Value == k2.Value { + newActions, err := walkTreesAndCollectActions( + path.WithKey(k2.Value), + v1, *v2) + if err != nil { + return nil, err + } + actions = append(actions, newActions...) + continue Outer + } + } + + // key not found in y1, so add it + newActions, err := walkTreesAndCollectActions( + path.WithKey(k2.Value), + nil, yaml.Node{ + Kind: y1.Kind, + Content: []*yaml.Node{k2, v2}, + }) + if err != nil { + return nil, err + } + + actions = append(actions, newActions...) + } + + // look for keys in y1 that are not in y2: remove them + for i := 0; i < len(y1.Content); i += 2 { + k1 := y1.Content[i] + + if _, alreadySeen := foundKeys[k1.Value]; alreadySeen { + continue + } + + actions = append(actions, Action{ + Target: path.WithKey(k1.Value).ToJSONPath(), + Remove: true, + }) + } + + return actions, nil +} diff --git a/overlay/compare_test.go b/overlay/compare_test.go new file mode 100644 index 0000000..be5e393 --- /dev/null +++ b/overlay/compare_test.go @@ -0,0 +1,40 @@ +package overlay_test + +import ( + "testing" + + "github.com/speakeasy-api/openapi/overlay" + "github.com/speakeasy-api/openapi/overlay/loader" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCompare(t *testing.T) { + t.Parallel() + + node, err := loader.LoadSpecification("testdata/openapi.yaml") + require.NoError(t, err) + node2, err := loader.LoadSpecification("testdata/openapi-overlayed.yaml") + require.NoError(t, err) + + o, err := loader.LoadOverlay("testdata/overlay-generated.yaml") + require.NoError(t, err) + + o2, err := overlay.Compare("Drinks Overlay", node, *node2) + require.NoError(t, err) + + o1s, err := o.ToString() + require.NoError(t, err) + o2s, err := o2.ToString() + require.NoError(t, err) + + // Uncomment this if we've improved the output + // os.WriteFile("testdata/overlay-generated.yaml", []byte(o2s), 0644) + assert.Equal(t, o1s, o2s) + + // round trip it + err = o.ApplyTo(node) + require.NoError(t, err) + NodeMatchesFile(t, node, "testdata/openapi-overlayed.yaml") + +} diff --git a/overlay/jsonpath.go b/overlay/jsonpath.go new file mode 100644 index 0000000..471c74e --- /dev/null +++ b/overlay/jsonpath.go @@ -0,0 +1,48 @@ +package overlay + +import ( + "fmt" + + "github.com/speakeasy-api/jsonpath/pkg/jsonpath" + "github.com/speakeasy-api/jsonpath/pkg/jsonpath/config" + "github.com/vmware-labs/yaml-jsonpath/pkg/yamlpath" + "gopkg.in/yaml.v3" +) + +type Queryable interface { + Query(root *yaml.Node) []*yaml.Node +} + +type yamlPathQueryable struct { + path *yamlpath.Path +} + +func (y yamlPathQueryable) Query(root *yaml.Node) []*yaml.Node { + if y.path == nil { + return []*yaml.Node{} + } + // errors aren't actually possible from yamlpath. + result, _ := y.path.Find(root) + return result +} + +func (o *Overlay) NewPath(target string, warnings *[]string) (Queryable, error) { + rfcJSONPath, rfcJSONPathErr := jsonpath.NewPath(target, config.WithPropertyNameExtension()) + if o.UsesRFC9535() { + return rfcJSONPath, rfcJSONPathErr + } + if rfcJSONPathErr != nil && warnings != nil { + *warnings = append(*warnings, fmt.Sprintf("invalid rfc9535 jsonpath %s: %s\nThis will be treated as an error in the future. Please fix and opt into the new implementation with `\"x-speakeasy-jsonpath\": rfc9535` in the root of your overlay. See overlay.speakeasy.com for an implementation playground.", target, rfcJSONPathErr.Error())) + } + + path, err := yamlpath.NewPath(target) + return mustExecute(path), err +} + +func (o *Overlay) UsesRFC9535() bool { + return o.JSONPathVersion == "rfc9535" +} + +func mustExecute(path *yamlpath.Path) yamlPathQueryable { + return yamlPathQueryable{path} +} diff --git a/overlay/loader/overlay.go b/overlay/loader/overlay.go new file mode 100644 index 0000000..16fbc47 --- /dev/null +++ b/overlay/loader/overlay.go @@ -0,0 +1,18 @@ +package loader + +import ( + "fmt" + + "github.com/speakeasy-api/openapi/overlay" +) + +// LoadOverlay is a tool for loading and parsing an overlay file from the file +// system. +func LoadOverlay(path string) (*overlay.Overlay, error) { + o, err := overlay.Parse(path) + if err != nil { + return nil, fmt.Errorf("failed to parse overlay from path %q: %w", path, err) + } + + return o, nil +} diff --git a/overlay/loader/spec.go b/overlay/loader/spec.go new file mode 100644 index 0000000..1f19463 --- /dev/null +++ b/overlay/loader/spec.go @@ -0,0 +1,80 @@ +package loader + +import ( + "errors" + "fmt" + "net/url" + "os" + + "github.com/speakeasy-api/openapi/overlay" + "gopkg.in/yaml.v3" +) + +// GetOverlayExtendsPath returns the path to file if the extends URL is a file +// URL. Otherwise, returns an empty string and an error. The error may occur if +// no extends URL is present or if the URL is not a file URL or if the URL is +// malformed. +func GetOverlayExtendsPath(o *overlay.Overlay) (string, error) { + if o.Extends == "" { + return "", errors.New("overlay does not specify an extends URL") + } + + specUrl, err := url.Parse(o.Extends) + if err != nil { + return "", fmt.Errorf("failed to parse URL %q: %w", o.Extends, err) + } + + if specUrl.Scheme != "file" { + return "", fmt.Errorf("only file:// extends URLs are supported, not %q", o.Extends) + } + + return specUrl.Path, nil +} + +// LoadExtendsSpecification will load and parse a YAML or JSON file as specified +// in the extends parameter of the overlay. Currently, this only supports file +// URLs. +func LoadExtendsSpecification(o *overlay.Overlay) (*yaml.Node, error) { + path, err := GetOverlayExtendsPath(o) + if err != nil { + return nil, err + } + + return LoadSpecification(path) +} + +// LoadSpecification will load and parse a YAML or JSON file from the given path. +func LoadSpecification(path string) (*yaml.Node, error) { + rs, err := os.Open(path) //nolint:gosec + if err != nil { + return nil, fmt.Errorf("failed to open schema from path %q: %w", path, err) + } + + var ys yaml.Node + err = yaml.NewDecoder(rs).Decode(&ys) + if err != nil { + return nil, fmt.Errorf("failed to parse schema at path %q: %w", path, err) + } + + return &ys, nil +} + +// LoadEitherSpecification is a convenience function that will load a +// specification from the given file path if it is non-empty. Otherwise, it will +// attempt to load the path from the overlay's extends URL. Also returns the name +// of the file loaded. +func LoadEitherSpecification(path string, o *overlay.Overlay) (*yaml.Node, string, error) { + var ( + y *yaml.Node + err error + ) + + if path != "" { + y, err = LoadSpecification(path) + } else { + path, _ = GetOverlayExtendsPath(o) + y, err = LoadExtendsSpecification(o) + } + + return y, path, err +} diff --git a/overlay/overlay_examples_test.go b/overlay/overlay_examples_test.go new file mode 100644 index 0000000..0f3eb5f --- /dev/null +++ b/overlay/overlay_examples_test.go @@ -0,0 +1,293 @@ +package overlay_test + +import ( + "fmt" + "os" + "strings" + + "github.com/speakeasy-api/openapi/overlay" + "github.com/speakeasy-api/openapi/overlay/loader" + "gopkg.in/yaml.v3" +) + +// Example_applying demonstrates how to apply an overlay to an OpenAPI document. +// Shows loading an overlay specification and applying it to transform an OpenAPI document. +func Example_applying() { + // Create temporary files for this example + overlayContent := `overlay: 1.0.0 +info: + title: Pet Store Enhancement Overlay + version: 1.0.0 +actions: + - target: $.info.description + update: Enhanced pet store API with additional features` + + openAPIContent := `openapi: 3.1.0 +info: + title: Pet Store API + version: 1.0.0 + description: A simple pet store API +paths: + /pets: + get: + summary: List pets + responses: + '200': + description: A list of pets` + + // Write temporary files + overlayFile := "temp_overlay.yaml" + openAPIFile := "temp_openapi.yaml" + if err := os.WriteFile(overlayFile, []byte(overlayContent), 0644); err != nil { + panic(err) + } + if err := os.WriteFile(openAPIFile, []byte(openAPIContent), 0644); err != nil { + panic(err) + } + defer os.Remove(overlayFile) + defer os.Remove(openAPIFile) + + // Parse the overlay + overlayDoc, err := overlay.Parse(overlayFile) + if err != nil { + panic(err) + } + + // Load the OpenAPI document + openAPINode, err := loader.LoadSpecification(openAPIFile) + if err != nil { + panic(err) + } + + // Apply the overlay to the OpenAPI document + err = overlayDoc.ApplyTo(openAPINode) + if err != nil { + panic(err) + } + + // Convert back to YAML string + var buf strings.Builder + encoder := yaml.NewEncoder(&buf) + encoder.SetIndent(2) + err = encoder.Encode(openAPINode) + if err != nil { + panic(err) + } + + fmt.Printf("Transformed document:\n%s", buf.String()) + // Output: + // Transformed document: + // openapi: 3.1.0 + // info: + // title: Pet Store API + // version: 1.0.0 + // description: Enhanced pet store API with additional features + // paths: + // /pets: + // get: + // summary: List pets + // responses: + // '200': + // description: A list of pets +} + +// Example_creating demonstrates how to create an overlay specification programmatically. +// Shows building an overlay specification with update and remove actions. +func Example_creating() { + // Create update value as yaml.Node + var updateNode yaml.Node + updateNode.SetString("Enhanced API with additional features") + + // Create an overlay with update and remove actions + overlayDoc := &overlay.Overlay{ + Version: "1.0.0", + Info: overlay.Info{ + Title: "API Enhancement Overlay", + Version: "1.0.0", + }, + Actions: []overlay.Action{ + { + Target: "$.info.description", + Update: updateNode, + }, + { + Target: "$.paths['/deprecated-endpoint']", + Remove: true, + }, + }, + } + + result, err := overlayDoc.ToString() + if err != nil { + panic(err) + } + + fmt.Printf("Overlay specification:\n%s", result) + // Output: + // Overlay specification: + // overlay: 1.0.0 + // info: + // title: API Enhancement Overlay + // version: 1.0.0 + // actions: + // - target: $.info.description + // update: Enhanced API with additional features + // - target: $.paths['/deprecated-endpoint'] + // remove: true +} + +// Example_parsing demonstrates how to parse an overlay specification from a file. +// Shows loading an overlay file and accessing its properties. +func Example_parsing() { + overlayContent := `overlay: 1.0.0 +info: + title: API Modification Overlay + version: 1.0.0 +actions: + - target: $.info.title + update: Enhanced Pet Store API + - target: $.info.version + update: 2.0.0` + + // Write temporary file + overlayFile := "temp_overlay.yaml" + if err := os.WriteFile(overlayFile, []byte(overlayContent), 0644); err != nil { + panic(err) + } + defer func() { _ = os.Remove(overlayFile) }() + + overlayDoc, err := overlay.Parse(overlayFile) + if err != nil { + panic(err) + } + + fmt.Printf("Overlay Version: %s\n", overlayDoc.Version) + fmt.Printf("Title: %s\n", overlayDoc.Info.Title) + fmt.Printf("Number of Actions: %d\n", len(overlayDoc.Actions)) + + for i, action := range overlayDoc.Actions { + fmt.Printf("Action %d Target: %s\n", i+1, action.Target) + } + // Output: + // Overlay Version: 1.0.0 + // Title: API Modification Overlay + // Number of Actions: 2 + // Action 1 Target: $.info.title + // Action 2 Target: $.info.version +} + +// Example_validating demonstrates how to validate an overlay specification. +// Shows loading and validating an overlay specification for correctness. +func Example_validating() { + // Invalid overlay specification (missing required fields) + invalidOverlay := `overlay: 1.0.0 +info: + title: Invalid Overlay +actions: + - target: $.info.title + description: Missing update or remove` + + // Write temporary file + overlayFile := "temp_invalid_overlay.yaml" + if err := os.WriteFile(overlayFile, []byte(invalidOverlay), 0644); err != nil { + panic(err) + } + defer func() { _ = os.Remove(overlayFile) }() + + overlayDoc, err := overlay.Parse(overlayFile) + if err != nil { + fmt.Printf("Parse error: %s\n", err.Error()) + return + } + + validationErr := overlayDoc.Validate() + if validationErr != nil { + fmt.Println("Validation errors:") + fmt.Printf(" %s\n", validationErr.Error()) + } else { + fmt.Println("Overlay specification is valid!") + } + // Output: + // Validation errors: + // overlay info version must be defined +} + +// Example_removing demonstrates how to use remove actions in overlays. +// Shows removing specific paths and properties from an OpenAPI document. +func Example_removing() { + // Sample OpenAPI document with endpoints to remove + openAPIContent := `openapi: 3.1.0 +info: + title: API + version: 1.0.0 +paths: + /users: + get: + summary: List users + /users/{id}: + get: + summary: Get user + /admin: + get: + summary: Admin endpoint + deprecated: true` + + // Overlay to remove deprecated endpoints + overlayContent := `overlay: 1.0.0 +info: + title: Cleanup Overlay + version: 1.0.0 +actions: + - target: $.paths['/admin'] + remove: true` + + // Write temporary files + openAPIFile := "temp_openapi.yaml" + overlayFile := "temp_overlay.yaml" + if err := os.WriteFile(openAPIFile, []byte(openAPIContent), 0644); err != nil { + panic(err) + } + if err := os.WriteFile(overlayFile, []byte(overlayContent), 0644); err != nil { + panic(err) + } + defer func() { _ = os.Remove(openAPIFile) }() + defer func() { _ = os.Remove(overlayFile) }() + + overlayDoc, err := overlay.Parse(overlayFile) + if err != nil { + panic(err) + } + + openAPINode, err := loader.LoadSpecification(openAPIFile) + if err != nil { + panic(err) + } + + err = overlayDoc.ApplyTo(openAPINode) + if err != nil { + panic(err) + } + + var buf strings.Builder + encoder := yaml.NewEncoder(&buf) + encoder.SetIndent(2) + err = encoder.Encode(openAPINode) + if err != nil { + panic(err) + } + + fmt.Printf("Document after removing deprecated endpoint:\n%s", buf.String()) + // Output: + // Document after removing deprecated endpoint: + // openapi: 3.1.0 + // info: + // title: API + // version: 1.0.0 + // paths: + // /users: + // get: + // summary: List users + // /users/{id}: + // get: + // summary: Get user +} diff --git a/overlay/parents.go b/overlay/parents.go new file mode 100644 index 0000000..a2b4942 --- /dev/null +++ b/overlay/parents.go @@ -0,0 +1,23 @@ +package overlay + +import "gopkg.in/yaml.v3" + +type parentIndex map[*yaml.Node]*yaml.Node + +// newParentIndex returns a new parentIndex, populated for the given root node. +func newParentIndex(root *yaml.Node) parentIndex { + index := parentIndex{} + index.indexNodeRecursively(root) + return index +} + +func (index parentIndex) indexNodeRecursively(parent *yaml.Node) { + for _, child := range parent.Content { + index[child] = parent + index.indexNodeRecursively(child) + } +} + +func (index parentIndex) getParent(child *yaml.Node) *yaml.Node { + return index[child] +} diff --git a/overlay/parse.go b/overlay/parse.go new file mode 100644 index 0000000..9cb8766 --- /dev/null +++ b/overlay/parse.go @@ -0,0 +1,59 @@ +package overlay + +import ( + "fmt" + "io" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +// Parse will parse the given reader as an overlay file. +func Parse(path string) (*Overlay, error) { + filePath, err := filepath.Abs(path) + if err != nil { + return nil, fmt.Errorf("failed to get absolute path for %q: %w", path, err) + } + + ro, err := os.Open(filePath) //nolint:gosec + if err != nil { + return nil, fmt.Errorf("failed to open overlay file at path %q: %w", path, err) + } + defer ro.Close() + + var overlay Overlay + dec := yaml.NewDecoder(ro) + + err = dec.Decode(&overlay) + if err != nil { + return nil, err + } + + return &overlay, err +} + +// Format will validate reformat the given file +func Format(path string) error { + overlay, err := Parse(path) + if err != nil { + return err + } + filePath, err := filepath.Abs(path) + if err != nil { + return fmt.Errorf("failed to open overlay file at path %q: %w", path, err) + } + formatted, err := overlay.ToString() + if err != nil { + return err + } + + return os.WriteFile(filePath, []byte(formatted), 0600) +} + +// Format writes the file back out as YAML. +func (o *Overlay) Format(w io.Writer) error { + enc := yaml.NewEncoder(w) + enc.SetIndent(2) + return enc.Encode(o) +} diff --git a/overlay/parse_test.go b/overlay/parse_test.go new file mode 100644 index 0000000..bb55d90 --- /dev/null +++ b/overlay/parse_test.go @@ -0,0 +1,27 @@ +package overlay_test + +import ( + "os" + "testing" + + "github.com/speakeasy-api/jsonpath/pkg/overlay" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParse(t *testing.T) { + t.Parallel() + + err := overlay.Format("testdata/overlay.yaml") + require.NoError(t, err) + o, err := overlay.Parse("testdata/overlay.yaml") + require.NoError(t, err) + assert.NotNil(t, o) + expect, err := os.ReadFile("testdata/overlay.yaml") + require.NoError(t, err) + + actual, err := o.ToString() + require.NoError(t, err) + assert.Equal(t, string(expect), actual) + +} diff --git a/overlay/schema.go b/overlay/schema.go new file mode 100644 index 0000000..6afa448 --- /dev/null +++ b/overlay/schema.go @@ -0,0 +1,67 @@ +package overlay + +import ( + "bytes" + + "gopkg.in/yaml.v3" +) + +// Extensible provides a place for extensions to be added to components of the +// Overlay configuration. These are a map from x-* extension fields to their values. +type Extensions map[string]any + +// Overlay is the top-level configuration for an OpenAPI overlay. +type Overlay struct { + Extensions `yaml:"-,inline"` + + // Version is the version of the overlay configuration. This is only ever expected to be 1.0.0 + Version string `yaml:"overlay"` + + // JSONPathVersion should be set to rfc9535, and is used for backwards compatibility purposes + JSONPathVersion string `yaml:"x-speakeasy-jsonpath,omitempty"` + + // Info describes the metadata for the overlay. + Info Info `yaml:"info"` + + // Extends is a URL to the OpenAPI specification this overlay applies to. + Extends string `yaml:"extends,omitempty"` + + // Actions is the list of actions to perform to apply the overlay. + Actions []Action `yaml:"actions"` +} + +func (o *Overlay) ToString() (string, error) { + buf := bytes.NewBuffer([]byte{}) + decoder := yaml.NewEncoder(buf) + decoder.SetIndent(2) + err := decoder.Encode(o) + return buf.String(), err +} + +// Info describes the metadata for the overlay. +type Info struct { + Extensions `yaml:"-,inline"` + + // Title is the title of the overlay. + Title string `yaml:"title"` + + // Version is the version of the overlay. + Version string `yaml:"version"` +} + +type Action struct { + Extensions `yaml:"-,inline"` + + // Target is the JSONPath to the target of the action. + Target string `yaml:"target"` + + // Description is a description of the action. + Description string `yaml:"description,omitempty"` + + // Update is the sub-document to use to merge or replace in the target. This is + // ignored if Remove is set. + Update yaml.Node `yaml:"update,omitempty"` + + // Remove marks the target node for removal rather than update. + Remove bool `yaml:"remove,omitempty"` +} diff --git a/overlay/testdata/openapi-overlayed.yaml b/overlay/testdata/openapi-overlayed.yaml new file mode 100644 index 0000000..b003efd --- /dev/null +++ b/overlay/testdata/openapi-overlayed.yaml @@ -0,0 +1,441 @@ +openapi: 3.1.0 +info: + title: The Speakeasy Bar + version: 1.0.0 + summary: A bar that serves drinks. + description: A secret underground bar that serves drinks to those in the know. + contact: + name: Speakeasy Support + url: https://support.speakeasy.bar + email: support@speakeasy.bar + license: + name: Apache 2.0 + url: https://www.apache.org/licenses/LICENSE-2.0.html + termsOfService: https://speakeasy.bar/terms +externalDocs: + url: https://docs.speakeasy.bar + description: The Speakeasy Bar Documentation. +servers: + - url: https://speakeasy.bar + description: The production server. + x-speakeasy-server-id: prod + - url: https://staging.speakeasy.bar + description: The staging server. + x-speakeasy-server-id: staging + - url: https://{organization}.{environment}.speakeasy.bar + description: A per-organization and per-environment API. + x-speakeasy-server-id: customer + variables: + organization: + description: The organization name. Defaults to a generic organization. + default: api + environment: + description: The environment name. Defaults to the production environment. + default: prod + enum: + - prod + - staging + - dev +security: + - apiKey: [] +tags: + - name: drinks + description: The drinks endpoints. + - name: ingredients + description: The ingredients endpoints. + - name: orders + description: The orders endpoints. + - name: authentication + x-something: else + - name: config + - name: Testing + description: just a description +paths: + x-speakeasy-errors: + statusCodes: # Defines status codes to handle as errors for all operations + - 4XX # Wildcard to handle all status codes in the 400-499 range + - 5XX + /anything/selectGlobalServer: + x-my-ignore: + servers: + - url: http://localhost:35123 + description: The default server. + get: + operationId: selectGlobalServer + responses: + "200": + description: OK + headers: + X-Optional-Header: + schema: + type: string + /authenticate: + post: + operationId: authenticate + summary: Authenticate with the API by providing a username and password. + security: [] + tags: + - authentication + - dont-add-x-drop-false + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + username: + type: string + password: + type: string + responses: + "200": + description: "" + content: + application/json: + schema: + type: object + properties: + token: + type: string + "401": + description: Invalid credentials provided. + "5XX": + $ref: "#/components/responses/APIError" + default: + $ref: "#/components/responses/UnknownError" + /drinks: + x-speakeasy-note: + "$ref": "./removeNote.yaml" + /drink/{name}: # Example comment -- should be maintained + get: + operationId: getDrink + summary: Get a drink. + description: | + A long description + to validate that we handle indentation properly + + With a second paragraph + tags: + - drinks + parameters: + - name: name + in: path + required: true + schema: + type: string + - x-parameter-extension: foo + name: test + description: Test parameter + in: query + schema: + type: string + responses: + "200": + description: Test response + content: + application/json: + schema: + $ref: "#/components/schemas/Drink" + type: string + x-response-extension: foo + "5XX": + $ref: "#/components/responses/APIError" + default: + $ref: "#/components/responses/UnknownError" + /ingredients: + get: + operationId: listIngredients + summary: Get a list of ingredients. + description: Get a list of ingredients, if authenticated this will include stock levels and product codes otherwise it will only include public information. + tags: + - ingredients + parameters: + - name: ingredients + in: query + description: A list of ingredients to filter by. If not provided all ingredients will be returned. + required: false + style: form + explode: false + schema: + type: array + items: + type: string + responses: + "200": + description: A list of ingredients. + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Ingredient" + "5XX": + $ref: "#/components/responses/APIError" + default: + $ref: "#/components/responses/UnknownError" + /order: + post: + operationId: createOrder + summary: Create an order. + description: Create an order for a drink. + tags: + - orders + parameters: + - name: callback_url + in: query + description: The url to call when the order is updated. + required: false + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Order" + responses: + "200": + description: The order was created successfully. + content: + application/json: + schema: + $ref: "#/components/schemas/Order" + "5XX": + $ref: "#/components/responses/APIError" + default: + $ref: "#/components/responses/UnknownError" + callbacks: + orderUpdate: + "{$request.query.callback_url}": + post: + summary: Receive order updates. + description: Receive order updates from the supplier, this will be called whenever the status of an order changes. + tags: + - orders + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + order: + $ref: "#/components/schemas/Order" + responses: + "200": + description: The order update was received successfully. + "5XX": + $ref: "#/components/responses/APIError" + default: + $ref: "#/components/responses/UnknownError" + /webhooks/subscribe: + post: + operationId: subscribeToWebhooks + summary: Subscribe to webhooks. + description: Subscribe to webhooks. + tags: + - config + requestBody: + required: true + content: + application/json: + schema: + type: array + items: + type: object + properties: + url: + type: string + webhook: + type: string + enum: + - stockUpdate + responses: + "200": + description: The webhook was subscribed to successfully. + "5XX": + $ref: "#/components/responses/APIError" + default: + $ref: "#/components/responses/UnknownError" +webhooks: + stockUpdate: + post: + summary: Receive stock updates. + description: Receive stock updates from the bar, this will be called whenever the stock levels of a drink or ingredient changes. + tags: + - drinks + - ingredients + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + drink: + $ref: "#/components/schemas/Drink" + ingredient: + $ref: "#/components/schemas/Ingredient" + responses: + "200": + description: The stock update was received successfully. + "5XX": + $ref: "#/components/responses/APIError" + default: + $ref: "#/components/responses/UnknownError" +components: + schemas: + APIError: + type: object + properties: + code: + type: string + message: + type: string + details: + type: object + additionalProperties: true + Error: + type: object + properties: + code: + type: string + message: + type: string + Drink: + type: object + properties: + name: + description: The name of the drink. + type: string + examples: + - Old Fashioned + - Manhattan + - Negroni + type: + $ref: "#/components/schemas/DrinkType" + price: + description: The price of one unit of the drink in US cents. + type: number + examples: + - 1000 # $10.00 + - 1200 # $12.00 + - 1500 # $15.00 + stock: + description: The number of units of the drink in stock, only available when authenticated. + type: integer + readOnly: true + productCode: + description: The product code of the drink, only available when authenticated. + type: string + examples: + - "AC-A2DF3" + - "NAC-3F2D1" + - "APM-1F2D3" + required: + - name + - price + DrinkType: + description: The type of drink. + type: string + enum: + - cocktail + - non-alcoholic + - beer + - wine + - spirit + - other + Ingredient: + type: object + properties: + name: + description: The name of the ingredient. + type: string + examples: + - Sugar Syrup + - Angostura Bitters + - Orange Peel + type: + $ref: "#/components/schemas/IngredientType" + stock: + description: The number of units of the ingredient in stock, only available when authenticated. + type: integer + examples: + - 10 + - 5 + - 0 + readOnly: true + productCode: + description: The product code of the ingredient, only available when authenticated. + type: string + examples: + - "AC-A2DF3" + - "NAC-3F2D1" + - "APM-1F2D3" + required: + - name + - type + IngredientType: + description: The type of ingredient. + type: string + enum: + - fresh + - long-life + - packaged + Order: + description: An order for a drink or ingredient. + type: object + properties: + type: + $ref: "#/components/schemas/OrderType" + productCode: + description: The product code of the drink or ingredient. + type: string + examples: + - "AC-A2DF3" + - "NAC-3F2D1" + - "APM-1F2D3" + quantity: + description: The number of units of the drink or ingredient to order. + type: integer + minimum: 1 + status: + description: The status of the order. + type: string + enum: + - pending + - processing + - complete + readOnly: true + required: + - type + - productCode + - quantity + - status + OrderType: + description: The type of order. + type: string + enum: + - drink + - ingredient + securitySchemes: + apiKey: + type: apiKey + name: Authorization + in: header + responses: + APIError: + description: An error occurred interacting with the API. + content: + application/json: + schema: + $ref: "#/components/schemas/APIError" + UnknownError: + description: An unknown error occurred interacting with the API. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" diff --git a/overlay/testdata/openapi-strict-onechange.yaml b/overlay/testdata/openapi-strict-onechange.yaml new file mode 100644 index 0000000..168277c --- /dev/null +++ b/overlay/testdata/openapi-strict-onechange.yaml @@ -0,0 +1,452 @@ +openapi: 3.1.0 +info: + title: changed + version: 1.0.0 + summary: A bar that serves drinks. + description: A secret underground bar that serves drinks to those in the know. + contact: + name: Speakeasy Support + url: https://support.speakeasy.bar + email: support@speakeasy.bar + license: + name: Apache 2.0 + url: https://www.apache.org/licenses/LICENSE-2.0.html + termsOfService: https://speakeasy.bar/terms +externalDocs: + url: https://docs.speakeasy.bar + description: The Speakeasy Bar Documentation. +servers: + - url: https://speakeasy.bar + description: The production server. + x-speakeasy-server-id: prod + - url: https://staging.speakeasy.bar + description: The staging server. + x-speakeasy-server-id: staging + - url: https://{organization}.{environment}.speakeasy.bar + description: A per-organization and per-environment API. + x-speakeasy-server-id: customer + variables: + organization: + description: The organization name. Defaults to a generic organization. + default: api + environment: + description: The environment name. Defaults to the production environment. + default: prod + enum: + - prod + - staging + - dev +security: + - apiKey: [] +tags: + - name: drinks + description: The drinks endpoints. + - name: ingredients + description: The ingredients endpoints. + - name: orders + description: The orders endpoints. + - name: authentication + description: The authentication endpoints. + x-something: else + - name: config +paths: + x-speakeasy-errors: + statusCodes: # Defines status codes to handle as errors for all operations + - 4XX # Wildcard to handle all status codes in the 400-499 range + - 5XX + /anything/selectGlobalServer: + x-my-ignore: true + get: + operationId: selectGlobalServer + responses: + "200": + description: OK + headers: + X-Optional-Header: + schema: + type: string + /authenticate: + post: + operationId: authenticate + summary: Authenticate with the API by providing a username and password. + security: [] + tags: + - authentication + - dont-add-x-drop-false + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + username: + type: string + password: + type: string + responses: + "200": + description: "" + content: + application/json: + schema: + type: object + properties: + token: + type: string + "401": + description: Invalid credentials provided. + "5XX": + $ref: "#/components/responses/APIError" + default: + $ref: "#/components/responses/UnknownError" + /drinks: + get: + x-speakeasy-usage-example: true + operationId: listDrinks + summary: Get a list of drinks. + description: Get a list of drinks, if authenticated this will include stock levels and product codes otherwise it will only include public information. + security: + - {} + tags: + - drinks + parameters: + - name: drinkType + in: query + description: The type of drink to filter by. If not provided all drinks will be returned. + required: false + schema: + $ref: "#/components/schemas/DrinkType" + responses: + "200": + description: A list of drinks. + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Drink" + "5XX": + $ref: "#/components/responses/APIError" + default: + $ref: "#/components/responses/UnknownError" + /drink/{name}: # Example comment -- should be maintained + get: + operationId: getDrink + summary: Get a drink. + description: Get a drink by name, if authenticated this will include stock levels and product codes otherwise it will only include public information. + tags: + - drinks + parameters: + - name: name + in: path + required: true + schema: + type: string + responses: + "200": + description: A drink. + content: + application/json: + schema: + $ref: "#/components/schemas/Drink" + "5XX": + $ref: "#/components/responses/APIError" + default: + $ref: "#/components/responses/UnknownError" + /ingredients: + get: + operationId: listIngredients + summary: Get a list of ingredients. + description: Get a list of ingredients, if authenticated this will include stock levels and product codes otherwise it will only include public information. + tags: + - ingredients + parameters: + - name: ingredients + in: query + description: A list of ingredients to filter by. If not provided all ingredients will be returned. + required: false + style: form + explode: false + schema: + type: array + items: + type: string + responses: + "200": + description: A list of ingredients. + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Ingredient" + "5XX": + $ref: "#/components/responses/APIError" + default: + $ref: "#/components/responses/UnknownError" + /order: + post: + operationId: createOrder + summary: Create an order. + description: Create an order for a drink. + tags: + - orders + parameters: + - name: callback_url + in: query + description: The url to call when the order is updated. + required: false + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Order" + responses: + "200": + description: The order was created successfully. + content: + application/json: + schema: + $ref: "#/components/schemas/Order" + "5XX": + $ref: "#/components/responses/APIError" + default: + $ref: "#/components/responses/UnknownError" + callbacks: + orderUpdate: + "{$request.query.callback_url}": + post: + summary: Receive order updates. + description: Receive order updates from the supplier, this will be called whenever the status of an order changes. + tags: + - orders + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + order: + $ref: "#/components/schemas/Order" + responses: + "200": + description: The order update was received successfully. + "5XX": + $ref: "#/components/responses/APIError" + default: + $ref: "#/components/responses/UnknownError" + /webhooks/subscribe: + post: + operationId: subscribeToWebhooks + summary: Subscribe to webhooks. + description: Subscribe to webhooks. + tags: + - config + requestBody: + required: true + content: + application/json: + schema: + type: array + items: + type: object + properties: + url: + type: string + webhook: + type: string + enum: + - stockUpdate + responses: + "200": + description: The webhook was subscribed to successfully. + "5XX": + $ref: "#/components/responses/APIError" + default: + $ref: "#/components/responses/UnknownError" +webhooks: + stockUpdate: + post: + summary: Receive stock updates. + description: Receive stock updates from the bar, this will be called whenever the stock levels of a drink or ingredient changes. + tags: + - drinks + - ingredients + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + drink: + $ref: "#/components/schemas/Drink" + ingredient: + $ref: "#/components/schemas/Ingredient" + responses: + "200": + description: The stock update was received successfully. + "5XX": + $ref: "#/components/responses/APIError" + default: + $ref: "#/components/responses/UnknownError" +components: + schemas: + APIError: + type: object + properties: + code: + type: string + message: + type: string + details: + type: object + additionalProperties: true + Error: + type: object + properties: + code: + type: string + message: + type: string + Drink: + type: object + properties: + name: + description: The name of the drink. + type: string + examples: + - Old Fashioned + - Manhattan + - Negroni + type: + $ref: "#/components/schemas/DrinkType" + price: + description: The price of one unit of the drink in US cents. + type: number + examples: + - 1000 # $10.00 + - 1200 # $12.00 + - 1500 # $15.00 + stock: + description: The number of units of the drink in stock, only available when authenticated. + type: integer + readOnly: true + productCode: + description: The product code of the drink, only available when authenticated. + type: string + examples: + - "AC-A2DF3" + - "NAC-3F2D1" + - "APM-1F2D3" + required: + - name + - price + DrinkType: + description: The type of drink. + type: string + enum: + - cocktail + - non-alcoholic + - beer + - wine + - spirit + - other + Ingredient: + type: object + properties: + name: + description: The name of the ingredient. + type: string + examples: + - Sugar Syrup + - Angostura Bitters + - Orange Peel + type: + $ref: "#/components/schemas/IngredientType" + stock: + description: The number of units of the ingredient in stock, only available when authenticated. + type: integer + examples: + - 10 + - 5 + - 0 + readOnly: true + productCode: + description: The product code of the ingredient, only available when authenticated. + type: string + examples: + - "AC-A2DF3" + - "NAC-3F2D1" + - "APM-1F2D3" + required: + - name + - type + IngredientType: + description: The type of ingredient. + type: string + enum: + - fresh + - long-life + - packaged + Order: + description: An order for a drink or ingredient. + type: object + properties: + type: + $ref: "#/components/schemas/OrderType" + productCode: + description: The product code of the drink or ingredient. + type: string + examples: + - "AC-A2DF3" + - "NAC-3F2D1" + - "APM-1F2D3" + quantity: + description: The number of units of the drink or ingredient to order. + type: integer + minimum: 1 + status: + description: The status of the order. + type: string + enum: + - pending + - processing + - complete + readOnly: true + required: + - type + - productCode + - quantity + - status + OrderType: + description: The type of order. + type: string + enum: + - drink + - ingredient + securitySchemes: + apiKey: + type: apiKey + name: Authorization + in: header + responses: + APIError: + description: An error occurred interacting with the API. + content: + application/json: + schema: + $ref: "#/components/schemas/APIError" + UnknownError: + description: An unknown error occurred interacting with the API. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" diff --git a/overlay/testdata/openapi.yaml b/overlay/testdata/openapi.yaml new file mode 100644 index 0000000..0b50639 --- /dev/null +++ b/overlay/testdata/openapi.yaml @@ -0,0 +1,457 @@ +openapi: 3.1.0 +info: + title: The Speakeasy Bar + version: 1.0.0 + summary: A bar that serves drinks. + description: A secret underground bar that serves drinks to those in the know. + contact: + name: Speakeasy Support + url: https://support.speakeasy.bar + email: support@speakeasy.bar + license: + name: Apache 2.0 + url: https://www.apache.org/licenses/LICENSE-2.0.html + termsOfService: https://speakeasy.bar/terms +externalDocs: + url: https://docs.speakeasy.bar + description: The Speakeasy Bar Documentation. +servers: + - url: https://speakeasy.bar + description: The production server. + x-speakeasy-server-id: prod + - url: https://staging.speakeasy.bar + description: The staging server. + x-speakeasy-server-id: staging + - url: https://{organization}.{environment}.speakeasy.bar + description: A per-organization and per-environment API. + x-speakeasy-server-id: customer + variables: + organization: + description: The organization name. Defaults to a generic organization. + default: api + environment: + description: The environment name. Defaults to the production environment. + default: prod + enum: + - prod + - staging + - dev +security: + - apiKey: [] +tags: + - name: drinks + description: The drinks endpoints. + - name: ingredients + description: The ingredients endpoints. + - name: orders + description: The orders endpoints. + - name: authentication + description: The authentication endpoints. + x-something: else + - name: config + +paths: + x-speakeasy-errors: + statusCodes: # Defines status codes to handle as errors for all operations + - 4XX # Wildcard to handle all status codes in the 400-499 range + - 5XX + /anything/selectGlobalServer: + x-my-ignore: true + get: + operationId: selectGlobalServer + responses: + "200": + description: OK + headers: + X-Optional-Header: + schema: + type: string + /authenticate: + post: + operationId: authenticate + summary: Authenticate with the API by providing a username and password. + security: [] + tags: + - authentication + - dont-add-x-drop-false + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + username: + type: string + password: + type: string + responses: + "200": + description: "" + content: + application/json: + schema: + type: object + properties: + token: + type: string + "401": + description: Invalid credentials provided. + "5XX": + $ref: "#/components/responses/APIError" + default: + $ref: "#/components/responses/UnknownError" + + /drinks: + get: + x-speakeasy-usage-example: true + operationId: listDrinks + summary: Get a list of drinks. + description: Get a list of drinks, if authenticated this will include stock levels and product codes otherwise it will only include public information. + security: + - {} + tags: + - drinks + parameters: + - name: drinkType + in: query + description: The type of drink to filter by. If not provided all drinks will be returned. + required: false + schema: + $ref: "#/components/schemas/DrinkType" + responses: + "200": + description: A list of drinks. + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Drink" + "5XX": + $ref: "#/components/responses/APIError" + default: + $ref: "#/components/responses/UnknownError" + + /drink/{name}: # Example comment -- should be maintained + get: + operationId: getDrink + summary: Get a drink. + description: Get a drink by name, if authenticated this will include stock levels and product codes otherwise it will only include public information. + tags: + - drinks + parameters: + - name: name + in: path + required: true + schema: + type: string + responses: + "200": + description: A drink. + content: + application/json: + schema: + $ref: "#/components/schemas/Drink" + "5XX": + $ref: "#/components/responses/APIError" + default: + $ref: "#/components/responses/UnknownError" + + /ingredients: + get: + operationId: listIngredients + summary: Get a list of ingredients. + description: Get a list of ingredients, if authenticated this will include stock levels and product codes otherwise it will only include public information. + tags: + - ingredients + parameters: + - name: ingredients + in: query + description: A list of ingredients to filter by. If not provided all ingredients will be returned. + required: false + style: form + explode: false + schema: + type: array + items: + type: string + responses: + "200": + description: A list of ingredients. + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Ingredient" + "5XX": + $ref: "#/components/responses/APIError" + default: + $ref: "#/components/responses/UnknownError" + + /order: + post: + operationId: createOrder + summary: Create an order. + description: Create an order for a drink. + tags: + - orders + parameters: + - name: callback_url + in: query + description: The url to call when the order is updated. + required: false + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Order" + responses: + "200": + description: The order was created successfully. + content: + application/json: + schema: + $ref: "#/components/schemas/Order" + "5XX": + $ref: "#/components/responses/APIError" + default: + $ref: "#/components/responses/UnknownError" + callbacks: + orderUpdate: + "{$request.query.callback_url}": + post: + summary: Receive order updates. + description: Receive order updates from the supplier, this will be called whenever the status of an order changes. + tags: + - orders + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + order: + $ref: "#/components/schemas/Order" + responses: + "200": + description: The order update was received successfully. + "5XX": + $ref: "#/components/responses/APIError" + default: + $ref: "#/components/responses/UnknownError" + /webhooks/subscribe: + post: + operationId: subscribeToWebhooks + summary: Subscribe to webhooks. + description: Subscribe to webhooks. + tags: + - config + requestBody: + required: true + content: + application/json: + schema: + type: array + items: + type: object + properties: + url: + type: string + webhook: + type: string + enum: + - stockUpdate + responses: + "200": + description: The webhook was subscribed to successfully. + "5XX": + $ref: "#/components/responses/APIError" + default: + $ref: "#/components/responses/UnknownError" +webhooks: + stockUpdate: + post: + summary: Receive stock updates. + description: Receive stock updates from the bar, this will be called whenever the stock levels of a drink or ingredient changes. + tags: + - drinks + - ingredients + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + drink: + $ref: "#/components/schemas/Drink" + ingredient: + $ref: "#/components/schemas/Ingredient" + responses: + "200": + description: The stock update was received successfully. + "5XX": + $ref: "#/components/responses/APIError" + default: + $ref: "#/components/responses/UnknownError" +components: + schemas: + APIError: + type: object + properties: + code: + type: string + message: + type: string + details: + type: object + additionalProperties: true + Error: + type: object + properties: + code: + type: string + message: + type: string + Drink: + type: object + properties: + name: + description: The name of the drink. + type: string + examples: + - Old Fashioned + - Manhattan + - Negroni + type: + $ref: "#/components/schemas/DrinkType" + price: + description: The price of one unit of the drink in US cents. + type: number + examples: + - 1000 # $10.00 + - 1200 # $12.00 + - 1500 # $15.00 + stock: + description: The number of units of the drink in stock, only available when authenticated. + type: integer + readOnly: true + productCode: + description: The product code of the drink, only available when authenticated. + type: string + examples: + - "AC-A2DF3" + - "NAC-3F2D1" + - "APM-1F2D3" + required: + - name + - price + DrinkType: + description: The type of drink. + type: string + enum: + - cocktail + - non-alcoholic + - beer + - wine + - spirit + - other + Ingredient: + type: object + properties: + name: + description: The name of the ingredient. + type: string + examples: + - Sugar Syrup + - Angostura Bitters + - Orange Peel + type: + $ref: "#/components/schemas/IngredientType" + stock: + description: The number of units of the ingredient in stock, only available when authenticated. + type: integer + examples: + - 10 + - 5 + - 0 + readOnly: true + productCode: + description: The product code of the ingredient, only available when authenticated. + type: string + examples: + - "AC-A2DF3" + - "NAC-3F2D1" + - "APM-1F2D3" + required: + - name + - type + IngredientType: + description: The type of ingredient. + type: string + enum: + - fresh + - long-life + - packaged + Order: + description: An order for a drink or ingredient. + type: object + properties: + type: + $ref: "#/components/schemas/OrderType" + productCode: + description: The product code of the drink or ingredient. + type: string + examples: + - "AC-A2DF3" + - "NAC-3F2D1" + - "APM-1F2D3" + quantity: + description: The number of units of the drink or ingredient to order. + type: integer + minimum: 1 + status: + description: The status of the order. + type: string + enum: + - pending + - processing + - complete + readOnly: true + required: + - type + - productCode + - quantity + - status + OrderType: + description: The type of order. + type: string + enum: + - drink + - ingredient + securitySchemes: + apiKey: + type: apiKey + name: Authorization + in: header + responses: + APIError: + description: An error occurred interacting with the API. + content: + application/json: + schema: + $ref: "#/components/schemas/APIError" + UnknownError: + description: An unknown error occurred interacting with the API. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" \ No newline at end of file diff --git a/overlay/testdata/overlay-generated.yaml b/overlay/testdata/overlay-generated.yaml new file mode 100644 index 0000000..5422849 --- /dev/null +++ b/overlay/testdata/overlay-generated.yaml @@ -0,0 +1,54 @@ +overlay: 1.0.0 +x-speakeasy-jsonpath: rfc9535 +info: + title: Drinks Overlay + version: 0.0.0 +actions: + - target: $["tags"][*] + remove: true + - target: $["tags"] + update: + - name: drinks + description: The drinks endpoints. + - name: ingredients + description: The ingredients endpoints. + - name: orders + description: The orders endpoints. + - name: authentication + x-something: else + - name: config + - name: Testing + description: just a description + - target: $["paths"]["/anything/selectGlobalServer"]["x-my-ignore"] + update: + servers: + - url: http://localhost:35123 + description: The default server. + - target: $["paths"]["/drinks"] + update: + x-speakeasy-note: + "$ref": "./removeNote.yaml" + - target: $["paths"]["/drinks"]["get"] + remove: true + - target: $["paths"]["/drink/{name}"]["get"]["description"] + update: | + A long description + to validate that we handle indentation properly + + With a second paragraph + - target: $["paths"]["/drink/{name}"]["get"]["parameters"] + update: + - x-parameter-extension: foo + name: test + description: Test parameter + in: query + schema: + type: string + - target: $["paths"]["/drink/{name}"]["get"]["responses"]["200"]["description"] + update: Test response + - target: $["paths"]["/drink/{name}"]["get"]["responses"]["200"]["content"]["application/json"]["schema"] + update: + type: string + - target: $["paths"]["/drink/{name}"]["get"]["responses"]["200"] + update: + x-response-extension: foo diff --git a/overlay/testdata/overlay-mismatched.yaml b/overlay/testdata/overlay-mismatched.yaml new file mode 100644 index 0000000..c0e4f6f --- /dev/null +++ b/overlay/testdata/overlay-mismatched.yaml @@ -0,0 +1,16 @@ +overlay: 1.0.0 +x-speakeasy-jsonpath: rfc9535 +info: + title: Drinks Overlay + version: 0.0.0 +actions: + - target: $["unknown-attribute"] + description: "failing overlay due to this element" + update: + description: just a description + - target: $.info.title + description: "should change info.title to changed" + update: "changed" + - target: $.info.title + description: "should produce a warning" + update: "changed" diff --git a/overlay/testdata/overlay-old.yaml b/overlay/testdata/overlay-old.yaml new file mode 100644 index 0000000..251f3c4 --- /dev/null +++ b/overlay/testdata/overlay-old.yaml @@ -0,0 +1,8 @@ +overlay: 1.0.0 +info: + title: Drinks Overlay + version: 1.2.3 + x-info-extension: 42 +actions: + - target: $.paths.*[?(@.x-my-ignore)] # this is non-compliant behaviour + remove: true diff --git a/overlay/testdata/overlay-zero-change.yaml b/overlay/testdata/overlay-zero-change.yaml new file mode 100644 index 0000000..73b678d --- /dev/null +++ b/overlay/testdata/overlay-zero-change.yaml @@ -0,0 +1,10 @@ +overlay: 1.0.0 +x-speakeasy-jsonpath: rfc9535 +info: + title: Drinks Overlay + version: 0.0.0 +actions: + - target: $.paths["/drink/{name}"].get.summary + update: "Read a drink." + - target: $.paths["/drink/{name}"].get.summary + update: "Get a drink." diff --git a/overlay/testdata/overlay.yaml b/overlay/testdata/overlay.yaml new file mode 100644 index 0000000..60769f5 --- /dev/null +++ b/overlay/testdata/overlay.yaml @@ -0,0 +1,53 @@ +overlay: 1.0.0 +x-speakeasy-jsonpath: rfc9535 +info: + title: Drinks Overlay + version: 1.2.3 + x-info-extension: 42 +actions: + - target: $.paths["/drink/{name}"].get + description: Test update + update: + parameters: + - x-parameter-extension: foo + name: test + description: Test parameter + in: query + schema: + type: string + responses: + '200': + x-response-extension: foo + description: Test response + content: + application/json: + schema: + type: string + x-action-extension: foo + - target: $.paths["/drinks"].get + description: Test remove + remove: true + x-action-extension: bar + - target: $.paths["/drinks"] + update: + x-speakeasy-note: + "$ref": "./removeNote.yaml" + - target: $.tags + update: + - name: Testing + description: just a description + - target: $.tags[?(@.name == "authentication")].description~ + remove: true + - target: $.paths["/anything/selectGlobalServer"]["x-my-ignore"] + update: + servers: + - url: http://localhost:35123 + description: The default server. + - target: $.paths["/drink/{name}"].get + update: + description: | + A long description + to validate that we handle indentation properly + + With a second paragraph +x-top-level-extension: true diff --git a/overlay/testdata/removeNote.yaml b/overlay/testdata/removeNote.yaml new file mode 100644 index 0000000..e19fe55 --- /dev/null +++ b/overlay/testdata/removeNote.yaml @@ -0,0 +1 @@ +this got removed.. \ No newline at end of file diff --git a/overlay/utils.go b/overlay/utils.go new file mode 100644 index 0000000..3843970 --- /dev/null +++ b/overlay/utils.go @@ -0,0 +1,20 @@ +package overlay + +import ( + "fmt" + + "gopkg.in/yaml.v3" +) + +func NewTargetSelector(path, method string) string { + return fmt.Sprintf(`$["paths"]["%s"]["%s"]`, path, method) +} + +func NewUpdateAction(path, method string, update yaml.Node) Action { + target := NewTargetSelector(path, method) + + return Action{ + Target: target, + Update: update, + } +} diff --git a/overlay/validate.go b/overlay/validate.go new file mode 100644 index 0000000..a2be9ab --- /dev/null +++ b/overlay/validate.go @@ -0,0 +1,62 @@ +package overlay + +import ( + "errors" + "fmt" + "net/url" + "strings" +) + +type ValidationErrors []error + +func (v ValidationErrors) Error() string { + msgs := make([]string, len(v)) + for i, err := range v { + msgs[i] = err.Error() + } + return strings.Join(msgs, "\n") +} + +func (v ValidationErrors) Return() error { + if len(v) > 0 { + return v + } + return nil +} + +func (o *Overlay) Validate() error { + errs := make(ValidationErrors, 0) + if o.Version != "1.0.0" { + errs = append(errs, errors.New("overlay version must be 1.0.0")) + } + + if o.Info.Title == "" { + errs = append(errs, errors.New("overlay info title must be defined")) + } + if o.Info.Version == "" { + errs = append(errs, errors.New("overlay info version must be defined")) + } + + if o.Extends != "" { + _, err := url.Parse(o.Extends) + if err != nil { + errs = append(errs, errors.New("overlay extends must be a valid URL")) + } + } + + if len(o.Actions) == 0 { + errs = append(errs, errors.New("overlay must define at least one action")) + } else { + for i, action := range o.Actions { + if action.Target == "" { + errs = append(errs, fmt.Errorf("overlay action at index %d target must be defined", i)) + } + + if action.Remove && !action.Update.IsZero() { + errs = append(errs, fmt.Errorf("overlay action at index %d should not both set remove and define update", i)) + } + } + } + + return errs.Return() +} From 3c0577f32adc8a5bf26e32b24d1b0e971bc883d2 Mon Sep 17 00:00:00 2001 From: Tristan Cartledge Date: Fri, 15 Aug 2025 16:50:19 +1000 Subject: [PATCH 2/5] feat: implement a CLI --- arazzo/cmd/root.go | 8 ++ arazzo/cmd/validate.go | 71 +++++++++++++++++ cmd/openapi/main.go | 89 ++++++++++++++++++++++ openapi/cmd/root.go | 9 +++ openapi/cmd/upgrade.go | 163 ++++++++++++++++++++++++++++++++++++++++ openapi/cmd/validate.go | 70 +++++++++++++++++ overlay/cmd/root.go | 14 +--- 7 files changed, 411 insertions(+), 13 deletions(-) create mode 100644 arazzo/cmd/root.go create mode 100644 arazzo/cmd/validate.go create mode 100644 cmd/openapi/main.go create mode 100644 openapi/cmd/root.go create mode 100644 openapi/cmd/upgrade.go create mode 100644 openapi/cmd/validate.go diff --git a/arazzo/cmd/root.go b/arazzo/cmd/root.go new file mode 100644 index 0000000..7c05731 --- /dev/null +++ b/arazzo/cmd/root.go @@ -0,0 +1,8 @@ +package cmd + +import "github.com/spf13/cobra" + +// Apply adds Arazzo commands to the provided root command +func Apply(rootCmd *cobra.Command) { + rootCmd.AddCommand(validateCmd) +} diff --git a/arazzo/cmd/validate.go b/arazzo/cmd/validate.go new file mode 100644 index 0000000..a4e169f --- /dev/null +++ b/arazzo/cmd/validate.go @@ -0,0 +1,71 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/speakeasy-api/openapi/arazzo" + "github.com/spf13/cobra" +) + +var validateCmd = &cobra.Command{ + Use: "validate ", + Short: "Validate an Arazzo workflow document", + Long: `Validate an Arazzo workflow document for compliance with the Arazzo Specification. + +This command will parse and validate the provided Arazzo document, checking for: +- Structural validity according to the Arazzo Specification +- Required fields and proper data types +- Workflow step dependencies and consistency +- Runtime expression validation +- Source description references`, + Args: cobra.ExactArgs(1), + Run: runValidate, +} + +func runValidate(cmd *cobra.Command, args []string) { + ctx := cmd.Context() + file := args[0] + + if err := validateArazzo(ctx, file); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} + +func validateArazzo(ctx context.Context, file string) error { + cleanFile := filepath.Clean(file) + fmt.Printf("Validating Arazzo document: %s\n", cleanFile) + + f, err := os.Open(cleanFile) + if err != nil { + return fmt.Errorf("failed to open file: %w", err) + } + defer f.Close() + + _, validationErrors, err := arazzo.Unmarshal(ctx, f) + if err != nil { + return fmt.Errorf("failed to unmarshal file: %w", err) + } + + if len(validationErrors) == 0 { + fmt.Printf("✅ Arazzo document is valid - 0 errors\n") + return nil + } + + fmt.Printf("❌ Arazzo document is invalid - %d errors:\n\n", len(validationErrors)) + + for i, validationErr := range validationErrors { + fmt.Printf("%d. %s\n", i+1, validationErr.Error()) + } + + return errors.New("arazzo document validation failed") +} + +// GetValidateCommand returns the validate command for external use +func GetValidateCommand() *cobra.Command { + return validateCmd +} diff --git a/cmd/openapi/main.go b/cmd/openapi/main.go new file mode 100644 index 0000000..4939d9a --- /dev/null +++ b/cmd/openapi/main.go @@ -0,0 +1,89 @@ +package main + +import ( + "fmt" + "os" + + arazzoCmd "github.com/speakeasy-api/openapi/arazzo/cmd" + openapiCmd "github.com/speakeasy-api/openapi/openapi/cmd" + overlayCmd "github.com/speakeasy-api/openapi/overlay/cmd" + "github.com/spf13/cobra" +) + +var ( + version = "dev" +) + +var rootCmd = &cobra.Command{ + Use: "openapi", + Short: "OpenAPI toolkit for working with OpenAPI specifications, overlays, and Arazzo workflows", + Long: `A comprehensive toolkit for working with OpenAPI specifications and Arazzo workflows. + +This CLI provides tools for: +- Validating OpenAPI specifications +- Validating Arazzo workflow documents +- Working with OpenAPI overlays (apply, compare, validate) +- Processing OpenAPI specifications +- Working with Arazzo workflow specifications +- Various utilities for OpenAPI and Arazzo development`, + Version: version, +} + +var overlayCmds = &cobra.Command{ + Use: "overlay", + Short: "Work with OpenAPI Overlays", + Long: `Commands for working with OpenAPI Overlays. + +OpenAPI Overlays provide a way to modify OpenAPI and Arazzo specifications +without directly editing the original files. This is useful for: +- Adding vendor-specific extensions +- Modifying specifications for different environments +- Applying transformations to third-party APIs`, +} + +var openapiCmds = &cobra.Command{ + Use: "openapi", + Short: "Work with OpenAPI specifications", + Long: `Commands for working with OpenAPI specifications. + +OpenAPI specifications define REST APIs in a standard format. +These commands help you validate and work with OpenAPI documents.`, +} + +var arazzoCmds = &cobra.Command{ + Use: "arazzo", + Short: "Work with Arazzo workflow documents", + Long: `Commands for working with Arazzo workflow documents. + +Arazzo workflows describe sequences of API calls and their dependencies. +These commands help you validate and work with Arazzo documents.`, +} + +func init() { + // Set version template + rootCmd.SetVersionTemplate(`{{printf "%s" .Version}}`) + + // Add OpenAPI spec validation command + openapiCmd.Apply(openapiCmds) + + // Add Arazzo workflow validation command + arazzoCmd.Apply(arazzoCmds) + + // Add overlay subcommands using the Apply function + overlayCmd.Apply(overlayCmds) + + // Add all commands to root + rootCmd.AddCommand(openapiCmds) + rootCmd.AddCommand(arazzoCmds) + rootCmd.AddCommand(overlayCmds) + + // Global flags + rootCmd.PersistentFlags().BoolP("verbose", "v", false, "verbose output") +} + +func main() { + if err := rootCmd.Execute(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} diff --git a/openapi/cmd/root.go b/openapi/cmd/root.go new file mode 100644 index 0000000..8d51795 --- /dev/null +++ b/openapi/cmd/root.go @@ -0,0 +1,9 @@ +package cmd + +import "github.com/spf13/cobra" + +// Apply adds OpenAPI commands to the provided root command +func Apply(rootCmd *cobra.Command) { + rootCmd.AddCommand(validateCmd) + rootCmd.AddCommand(upgradeCmd) +} diff --git a/openapi/cmd/upgrade.go b/openapi/cmd/upgrade.go new file mode 100644 index 0000000..43e7382 --- /dev/null +++ b/openapi/cmd/upgrade.go @@ -0,0 +1,163 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/speakeasy-api/openapi/marshaller" + "github.com/speakeasy-api/openapi/openapi" + "github.com/spf13/cobra" +) + +var upgradeCmd = &cobra.Command{ + Use: "upgrade [output-file]", + Short: "Upgrade an OpenAPI specification to the latest supported version", + Long: `Upgrade an OpenAPI specification document to the latest supported version (3.1.1). + +This command will upgrade OpenAPI documents from: +- OpenAPI 3.0.x versions to 3.1.1 (always) +- OpenAPI 3.1.x versions to 3.1.1 (by default) +- Use --minor-only to only upgrade minor versions (3.0.x to 3.1.1, but skip 3.1.x versions) + +The upgrade process includes: +- Updating the OpenAPI version field +- Converting nullable properties to proper JSON Schema format +- Updating schema validation rules +- Maintaining backward compatibility where possible + +Output options: +- No output file specified: writes to stdout (pipe-friendly) +- Output file specified: writes to the specified file +- --write flag: writes in-place to the input file`, + Args: cobra.RangeArgs(1, 2), + Run: runUpgrade, +} + +var ( + minorOnly bool + writeInPlace bool +) + +func init() { + upgradeCmd.Flags().BoolVar(&minorOnly, "minor-only", false, "only upgrade minor versions (3.0.x to 3.1.1, skip 3.1.x versions)") + upgradeCmd.Flags().BoolVarP(&writeInPlace, "write", "w", false, "write result in-place to input file") +} + +func runUpgrade(cmd *cobra.Command, args []string) { + ctx := cmd.Context() + inputFile := args[0] + + var outputFile string + if writeInPlace { + if len(args) > 1 { + fmt.Fprintf(os.Stderr, "Error: cannot specify output file when using --write flag\n") + os.Exit(1) + } + outputFile = inputFile + } else if len(args) > 1 { + outputFile = args[1] + } + + if err := upgradeOpenAPI(ctx, inputFile, outputFile, minorOnly); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} + +func upgradeOpenAPI(ctx context.Context, inputFile, outputFile string, minorOnly bool) error { + cleanInputFile := filepath.Clean(inputFile) + + // Only print status messages if not writing to stdout (to keep stdout clean for piping) + writeToStdout := outputFile == "" + if !writeToStdout { + fmt.Printf("Upgrading OpenAPI document: %s\n", cleanInputFile) + } + + // Read the input file + f, err := os.Open(cleanInputFile) + if err != nil { + return fmt.Errorf("failed to open input file: %w", err) + } + defer f.Close() + + // Parse the OpenAPI document + doc, validationErrors, err := openapi.Unmarshal(ctx, f) + if err != nil { + return fmt.Errorf("failed to unmarshal OpenAPI document: %w", err) + } + if doc == nil { + return errors.New("failed to parse OpenAPI document: document is nil") + } + + // Report validation errors but continue with upgrade (only if not writing to stdout) + if len(validationErrors) > 0 && !writeToStdout { + fmt.Printf("⚠️ Found %d validation errors in original document:\n", len(validationErrors)) + for i, validationErr := range validationErrors { + fmt.Printf(" %d. %s\n", i+1, validationErr.Error()) + } + fmt.Println() + } + + // Prepare upgrade options + var opts []openapi.Option[openapi.UpgradeOptions] + if !minorOnly { + // By default, upgrade all versions including patch versions (3.1.x to 3.1.1) + opts = append(opts, openapi.WithUpgradeSamePatchVersion()) + } + // When minorOnly is true, only 3.0.x versions will be upgraded to 3.1.1 + // 3.1.x versions will be skipped unless they need minor version upgrade + + // Perform the upgrade + originalVersion := doc.OpenAPI + upgraded, err := openapi.Upgrade(ctx, doc, opts...) + if err != nil { + return fmt.Errorf("failed to upgrade OpenAPI document: %w", err) + } + + if !upgraded { + if !writeToStdout { + fmt.Printf("📋 No upgrade needed - document is already at version %s\n", originalVersion) + } + // Still output the document even if no upgrade was needed + return writeOutput(ctx, doc, outputFile, writeToStdout) + } + + if !writeToStdout { + fmt.Printf("✅ Successfully upgraded from %s to %s\n", originalVersion, doc.OpenAPI) + } + + return writeOutput(ctx, doc, outputFile, writeToStdout) +} + +func writeOutput(ctx context.Context, doc *openapi.OpenAPI, outputFile string, writeToStdout bool) error { + if writeToStdout { + // Write to stdout (pipe-friendly) + return marshaller.Marshal(ctx, doc, os.Stdout) + } + + // Write to file + cleanOutputFile := filepath.Clean(outputFile) + outFile, err := os.Create(cleanOutputFile) + if err != nil { + return fmt.Errorf("failed to create output file: %w", err) + } + defer outFile.Close() + + if err := marshaller.Marshal(ctx, doc, outFile); err != nil { + return fmt.Errorf("failed to write upgraded document: %w", err) + } + + if cleanOutputFile == filepath.Clean(outputFile) { + fmt.Printf("📄 Upgraded document written to: %s\n", cleanOutputFile) + } + + return nil +} + +// GetUpgradeCommand returns the upgrade command for external use +func GetUpgradeCommand() *cobra.Command { + return upgradeCmd +} diff --git a/openapi/cmd/validate.go b/openapi/cmd/validate.go new file mode 100644 index 0000000..1e9c834 --- /dev/null +++ b/openapi/cmd/validate.go @@ -0,0 +1,70 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/speakeasy-api/openapi/openapi" + "github.com/spf13/cobra" +) + +var validateCmd = &cobra.Command{ + Use: "validate ", + Short: "Validate an OpenAPI specification document", + Long: `Validate an OpenAPI specification document for compliance with the OpenAPI Specification. + +This command will parse and validate the provided OpenAPI document, checking for: +- Structural validity according to the OpenAPI Specification +- Required fields and proper data types +- Reference resolution and consistency +- Schema validation rules`, + Args: cobra.ExactArgs(1), + Run: runValidate, +} + +func runValidate(cmd *cobra.Command, args []string) { + ctx := cmd.Context() + file := args[0] + + if err := validateOpenAPI(ctx, file); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} + +func validateOpenAPI(ctx context.Context, file string) error { + cleanFile := filepath.Clean(file) + fmt.Printf("Validating OpenAPI document: %s\n", cleanFile) + + f, err := os.Open(cleanFile) + if err != nil { + return fmt.Errorf("failed to open file: %w", err) + } + defer f.Close() + + _, validationErrors, err := openapi.Unmarshal(ctx, f) + if err != nil { + return fmt.Errorf("failed to unmarshal file: %w", err) + } + + if len(validationErrors) == 0 { + fmt.Printf("✅ OpenAPI document is valid - 0 errors\n") + return nil + } + + fmt.Printf("❌ OpenAPI document is invalid - %d errors:\n\n", len(validationErrors)) + + for i, validationErr := range validationErrors { + fmt.Printf("%d. %s\n", i+1, validationErr.Error()) + } + + return errors.New("openAPI document validation failed") +} + +// GetValidateCommand returns the validate command for external use +func GetValidateCommand() *cobra.Command { + return validateCmd +} diff --git a/overlay/cmd/root.go b/overlay/cmd/root.go index 9892c78..33bdf31 100644 --- a/overlay/cmd/root.go +++ b/overlay/cmd/root.go @@ -2,20 +2,8 @@ package cmd import "github.com/spf13/cobra" -var ( - rootCmd = &cobra.Command{ - Use: "openapi-overlay", - Short: "Work with OpenAPI Overlays", - } -) - -func init() { +func Apply(rootCmd *cobra.Command) { rootCmd.AddCommand(applyCmd) rootCmd.AddCommand(compareCmd) rootCmd.AddCommand(validateCmd) } - -func Execute() { - err := rootCmd.Execute() - cobra.CheckErr(err) -} From cf1e1c9a9202b8d9fcce491fdee9945d0db7a7d5 Mon Sep 17 00:00:00 2001 From: Tristan Cartledge Date: Tue, 19 Aug 2025 12:39:22 +1000 Subject: [PATCH 3/5] feat: add an installable CLI for openapi, overlay and arazzo operations --- README.md | 49 + arazzo/cmd/README.md | 91 ++ cmd/openapi/main.go | 24 +- hashing/hashing.go | 40 +- hashing/hashing_test.go | 68 ++ marshaller/unmarshaller.go | 4 +- openapi/bundle.go | 697 +++++++++++++ openapi/bundle_test.go | 141 +++ openapi/cmd/README.md | 230 +++++ openapi/cmd/bundle.go | 113 +++ openapi/cmd/inline.go | 108 +++ openapi/cmd/root.go | 2 + openapi/cmd/shared.go | 116 +++ openapi/cmd/upgrade.go | 86 +- openapi/core/reference.go | 55 +- openapi/inline.go | 402 ++++++++ openapi/inline_test.go | 85 ++ openapi/reference_inline_test.go | 56 ++ .../inline/bundled_counter_current.yaml | 661 +++++++++++++ .../inline/bundled_counter_expected.yaml | 661 +++++++++++++ openapi/testdata/inline/bundled_current.yaml | 661 +++++++++++++ openapi/testdata/inline/bundled_expected.yaml | 661 +++++++++++++ openapi/testdata/inline/external_api.yaml | 129 +++ .../inline/external_conflicting_user.yaml | 34 + openapi/testdata/inline/external_post.yaml | 33 + .../inline/external_simple_schema.yaml | 19 + openapi/testdata/inline/external_user.yaml | 27 + .../inline/external_user_preferences.yaml | 16 + .../inline/external_user_profile.yaml | 18 + openapi/testdata/inline/inline_current.yaml | 914 ++++++++++++++++++ openapi/testdata/inline/inline_expected.yaml | 914 ++++++++++++++++++ openapi/testdata/inline/inline_input.yaml | 583 +++++++++++ overlay/cmd/README.md | 161 ++- 33 files changed, 7747 insertions(+), 112 deletions(-) create mode 100644 arazzo/cmd/README.md create mode 100644 openapi/bundle.go create mode 100644 openapi/bundle_test.go create mode 100644 openapi/cmd/README.md create mode 100644 openapi/cmd/bundle.go create mode 100644 openapi/cmd/inline.go create mode 100644 openapi/cmd/shared.go create mode 100644 openapi/inline.go create mode 100644 openapi/inline_test.go create mode 100644 openapi/reference_inline_test.go create mode 100644 openapi/testdata/inline/bundled_counter_current.yaml create mode 100644 openapi/testdata/inline/bundled_counter_expected.yaml create mode 100644 openapi/testdata/inline/bundled_current.yaml create mode 100644 openapi/testdata/inline/bundled_expected.yaml create mode 100644 openapi/testdata/inline/external_api.yaml create mode 100644 openapi/testdata/inline/external_conflicting_user.yaml create mode 100644 openapi/testdata/inline/external_post.yaml create mode 100644 openapi/testdata/inline/external_simple_schema.yaml create mode 100644 openapi/testdata/inline/external_user.yaml create mode 100644 openapi/testdata/inline/external_user_preferences.yaml create mode 100644 openapi/testdata/inline/external_user_profile.yaml create mode 100644 openapi/testdata/inline/inline_current.yaml create mode 100644 openapi/testdata/inline/inline_expected.yaml create mode 100644 openapi/testdata/inline/inline_input.yaml diff --git a/README.md b/README.md index 9e52605..ef096fb 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,54 @@ The `arazzo` package provides an API for working with Arazzo documents including The `openapi` package provides an API for working with OpenAPI documents including reading, creating, mutating, walking, validating and upgrading them. Supports both OpenAPI 3.0.x and 3.1.x specifications. +### [overlay](./overlay) + +The `overlay` package provides an API for working with OpenAPI Overlays including applying overlays to specifications, comparing specifications to generate overlays, and validating overlay documents. + +## CLI Tool + +This repository also provides a comprehensive CLI tool for working with OpenAPI specifications, Arazzo workflows, and OpenAPI overlays. + +### Installation + +Install the CLI tool using Go: + +```bash +go install github.com/speakeasy-api/openapi/cmd/openapi@latest +``` + +### Usage + +The CLI provides three main command groups: + +- **`openapi openapi`** - Commands for working with OpenAPI specifications ([documentation](./openapi/cmd/README.md)) +- **`openapi arazzo`** - Commands for working with Arazzo workflow documents ([documentation](./arazzo/cmd/README.md)) +- **`openapi overlay`** - Commands for working with OpenAPI overlays ([documentation](./overlay/cmd/README.md)) + +#### Quick Examples + +```bash +# Validate an OpenAPI specification +openapi openapi validate ./spec.yaml + +# Bundle external references into components section +openapi openapi bundle ./spec.yaml ./bundled-spec.yaml + +# Inline all references to create a self-contained document +openapi openapi inline ./spec.yaml ./inlined-spec.yaml + +# Upgrade OpenAPI spec to latest version +openapi openapi upgrade ./spec.yaml ./upgraded-spec.yaml + +# Apply an overlay to a specification +openapi overlay apply --overlay overlay.yaml --schema spec.yaml + +# Validate an Arazzo workflow document +openapi arazzo validate ./workflow.arazzo.yaml +``` + +For detailed usage instructions for each command group, see the individual documentation linked above. + ## Sub Packages This repository also contains a number of sub packages that are used by the main packages to provide the required functionality. The below packages may be moved into their own repository in the future, depending on future needs. @@ -81,3 +129,4 @@ This repository is maintained by Speakeasy, but we welcome and encourage contrib All contributions, whether they're bug reports, feature requests, or code changes, help make this project better for everyone. Please ensure your contributions adhere to our coding standards and include appropriate tests where applicable. + diff --git a/arazzo/cmd/README.md b/arazzo/cmd/README.md new file mode 100644 index 0000000..5436916 --- /dev/null +++ b/arazzo/cmd/README.md @@ -0,0 +1,91 @@ +# Arazzo Commands + +Commands for working with Arazzo workflow documents. + +Arazzo workflows describe sequences of API calls and their dependencies. These commands help you validate and work with Arazzo documents according to the [Arazzo Specification](https://spec.openapis.org/arazzo/v1.0.0). + +## Available Commands + +### `validate` + +Validate an Arazzo workflow document for compliance with the Arazzo Specification. + +```bash +# Validate an Arazzo workflow file +openapi arazzo validate ./workflow.arazzo.yaml + +# Validate with verbose output +openapi arazzo validate -v ./workflow.arazzo.yaml +``` + +This command checks for: + +- Structural validity according to the Arazzo Specification +- Workflow step definitions and dependencies +- Parameter and expression syntax +- Source description references and validity +- Step success and failure action definitions + +## What is Arazzo? + +Arazzo is a specification for describing API workflows - sequences of API calls where the output of one call can be used as input to subsequent calls. It's designed to work alongside OpenAPI specifications to describe complex API interactions. + +### Example Arazzo Document + +```yaml +arazzo: 1.0.0 +info: + title: User Management Workflow + version: 1.0.0 + +sourceDescriptions: + - name: userAPI + url: ./user-api.yaml + type: openapi + +workflows: + - workflowId: createUserWorkflow + summary: Create a new user and fetch their profile + steps: + - stepId: createUser + description: Create a new user + operationId: userAPI.createUser + requestBody: + contentType: application/json + payload: + name: $inputs.userName + email: $inputs.userEmail + successCriteria: + - condition: $statusCode == 201 + outputs: + userId: $response.body.id + + - stepId: getUserProfile + description: Fetch the created user's profile + operationId: userAPI.getUser + dependsOn: createUser + parameters: + - name: userId + in: path + value: $steps.createUser.outputs.userId + successCriteria: + - condition: $statusCode == 200 +``` + +## Common Use Cases + +**API Testing Workflows**: Test user registration and login flows +**Data Processing Pipelines**: Process data through multiple API endpoints +**Integration Testing**: Test integration between multiple services +**Automated Workflows**: Chain API calls for business process automation + +## Common Options + +All commands support these common options: + +- `-h, --help`: Show help for the command +- `-v, --verbose`: Enable verbose output (global flag) + +## Output Formats + +All commands work with both YAML and JSON input files. Validation results provide clear, structured feedback with specific error locations and descriptions. \ No newline at end of file diff --git a/cmd/openapi/main.go b/cmd/openapi/main.go index 4939d9a..ef9ba8f 100644 --- a/cmd/openapi/main.go +++ b/cmd/openapi/main.go @@ -20,12 +20,24 @@ var rootCmd = &cobra.Command{ Long: `A comprehensive toolkit for working with OpenAPI specifications and Arazzo workflows. This CLI provides tools for: -- Validating OpenAPI specifications -- Validating Arazzo workflow documents -- Working with OpenAPI overlays (apply, compare, validate) -- Processing OpenAPI specifications -- Working with Arazzo workflow specifications -- Various utilities for OpenAPI and Arazzo development`, + +OpenAPI Specifications: +- Validate OpenAPI specification documents for compliance +- Upgrade OpenAPI specs to the latest supported version (3.1.1) +- Inline all references to create self-contained documents +- Bundle external references into components section while preserving structure + +Arazzo Workflows: +- Validate Arazzo workflow documents for compliance + +OpenAPI Overlays: +- Apply overlays to modify OpenAPI specifications +- Compare two specifications and generate overlays +- Validate overlay files for correctness + +Each command group provides specialized functionality for working with different +aspects of the OpenAPI ecosystem, from basic validation to advanced document +transformation and workflow management.`, Version: version, } diff --git a/hashing/hashing.go b/hashing/hashing.go index 9aafebb..645fa7f 100644 --- a/hashing/hashing.go +++ b/hashing/hashing.go @@ -9,11 +9,13 @@ import ( "strings" "github.com/speakeasy-api/openapi/internal/interfaces" + "gopkg.in/yaml.v3" ) func Hash(v any) string { hasher := fnv.New64a() - _, _ = hasher.Write([]byte(toHashableString(v))) + hashableStr := toHashableString(v) + _, _ = hasher.Write([]byte(hashableStr)) return fmt.Sprintf("%016x", hasher.Sum64()) } @@ -62,7 +64,12 @@ func toHashableString(v any) string { builder.WriteString(toHashableString(mapVal.MapIndex(key).Interface())) } case reflect.Struct: - builder.WriteString(structToHashableString(v)) + // Check if this is a yaml.Node + if node, ok := v.(yaml.Node); ok { + builder.WriteString(yamlNodeToHashableString(&node)) + } else { + builder.WriteString(structToHashableString(v)) + } case reflect.Ptr, reflect.Interface: val := reflect.ValueOf(v) if val.IsNil() { @@ -81,6 +88,8 @@ func toHashableString(v any) string { builder.WriteString(v) case int: builder.WriteString(strconv.Itoa(v)) + case *yaml.Node: + builder.WriteString(yamlNodeToHashableString(v)) default: builder.WriteString(fmt.Sprintf("%v", v)) } @@ -140,6 +149,33 @@ func structToHashableString(v any) string { return builder.String() } +// yamlNodeToHashableString recursively processes a YAML node and its children, +// including only semantic content (Tag, Value, Kind) and excluding positional +// metadata (Line, Column, Style, etc.) +func yamlNodeToHashableString(node *yaml.Node) string { + if node == nil { + return "" + } + + var builder strings.Builder + + // Include semantic fields only + builder.WriteString(fmt.Sprintf("Kind%d", node.Kind)) + if node.Tag != "" { + builder.WriteString("Tag" + node.Tag) + } + if node.Value != "" { + builder.WriteString("Value" + node.Value) + } + + // Recursively process children in Content array + for _, child := range node.Content { + builder.WriteString(yamlNodeToHashableString(child)) + } + + return builder.String() +} + func sequencedMapToHashableString(v any) string { var builder strings.Builder diff --git a/hashing/hashing_test.go b/hashing/hashing_test.go index 0a28b54..064c70f 100644 --- a/hashing/hashing_test.go +++ b/hashing/hashing_test.go @@ -1,16 +1,19 @@ package hashing import ( + "strings" "testing" "github.com/speakeasy-api/openapi/extensions" "github.com/speakeasy-api/openapi/jsonschema/oas3" + "github.com/speakeasy-api/openapi/marshaller" "github.com/speakeasy-api/openapi/marshaller/tests" "github.com/speakeasy-api/openapi/pointer" "github.com/speakeasy-api/openapi/references" "github.com/speakeasy-api/openapi/sequencedmap" "github.com/speakeasy-api/openapi/yml" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) type testEnum string @@ -534,3 +537,68 @@ func TestHash_JSONSchemaReferenceVsResolved(t *testing.T) { }) } } + +func TestHash_JSONSchemaFieldOrdering(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + // Two identical User schemas with different field ordering + userSchema1YAML := `type: object +properties: + id: + type: string + format: uuid + name: + type: string + maxLength: 100 + minLength: 1 + email: + type: string + format: email +required: + - id + - name + - email` + + userSchema2YAML := `type: object +required: + - id + - name + - email +properties: + id: + type: string + format: uuid + name: + type: string + minLength: 1 + maxLength: 100 + email: + type: string + format: email` + + // Parse both schemas using marshaller.Unmarshal + var schema1 oas3.Schema + _, err := marshaller.Unmarshal(ctx, strings.NewReader(userSchema1YAML), &schema1) + require.NoError(t, err, "should parse first schema") + _ = schema1.Validate(ctx) + + var schema2 oas3.Schema + _, err = marshaller.Unmarshal(ctx, strings.NewReader(userSchema2YAML), &schema2) + require.NoError(t, err, "should parse second schema") + + // Create JSONSchema wrappers + jsonSchema1 := oas3.NewJSONSchemaFromSchema[oas3.Referenceable](&schema1) + jsonSchema2 := oas3.NewJSONSchemaFromSchema[oas3.Referenceable](&schema2) + + // Hash both schemas + hash1 := Hash(jsonSchema1) + hash2 := Hash(jsonSchema2) + + t.Logf("Schema 1 hash: %s", hash1) + t.Logf("Schema 2 hash: %s", hash2) + + // They should be equal since they represent the same schema + assert.Equal(t, hash1, hash2, "identical schemas should have the same hash regardless of field ordering") +} diff --git a/marshaller/unmarshaller.go b/marshaller/unmarshaller.go index 1e34daf..ea69193 100644 --- a/marshaller/unmarshaller.go +++ b/marshaller/unmarshaller.go @@ -30,12 +30,12 @@ type Unmarshallable interface { } // Unmarshal will unmarshal the provided document into the specified model. -func Unmarshal[T any](ctx context.Context, doc io.Reader, out CoreAccessor[T]) ([]error, error) { +func Unmarshal[T any](ctx context.Context, in io.Reader, out CoreAccessor[T]) ([]error, error) { if out == nil || reflect.ValueOf(out).IsNil() { return nil, errors.New("out parameter cannot be nil") } - data, err := io.ReadAll(doc) + data, err := io.ReadAll(in) if err != nil { return nil, fmt.Errorf("failed to read document: %w", err) } diff --git a/openapi/bundle.go b/openapi/bundle.go new file mode 100644 index 0000000..130e512 --- /dev/null +++ b/openapi/bundle.go @@ -0,0 +1,697 @@ +package openapi + +import ( + "context" + "fmt" + "path/filepath" + "regexp" + "strings" + + "github.com/speakeasy-api/openapi/hashing" + "github.com/speakeasy-api/openapi/internal/interfaces" + "github.com/speakeasy-api/openapi/internal/utils" + "github.com/speakeasy-api/openapi/jsonschema/oas3" + "github.com/speakeasy-api/openapi/marshaller" + "github.com/speakeasy-api/openapi/references" + "github.com/speakeasy-api/openapi/sequencedmap" +) + +// BundleNamingStrategy defines how external references should be named when bundled. +type BundleNamingStrategy int + +const ( + // BundleNamingCounter uses counter-based suffixes like User_1, User_2 for conflicts + BundleNamingCounter BundleNamingStrategy = iota + // BundleNamingFilePath uses file path-based naming like file_path_somefile_yaml~User + BundleNamingFilePath +) + +// BundleOptions represents the options available when bundling an OpenAPI document. +type BundleOptions struct { + // ResolveOptions are the options to use when resolving references during bundling. + ResolveOptions ResolveOptions + // NamingStrategy determines how external references are named when brought into components. + NamingStrategy BundleNamingStrategy +} + +// Bundle transforms an OpenAPI document by bringing all external references into the components section, +// creating a self-contained document that maintains the reference structure but doesn't depend on external files. +// This operation modifies the document in place. +// +// Why use Bundle? +// +// - **Create portable documents**: Combine multiple OpenAPI files into a single document while preserving references +// - **Maintain reference structure**: Keep the benefits of references for tooling that supports them +// - **Simplify distribution**: Share a single file that contains all dependencies +// - **Optimize for reference-aware tools**: Provide complete documents to tools that work well with references +// - **Prepare for further processing**: Create a foundation for subsequent inlining or other transformations +// - **Handle complex API architectures**: Combine modular API definitions into unified specifications +// +// What you'll get: +// +// Before bundling: +// +// { +// "openapi": "3.1.0", +// "paths": { +// "/users": { +// "get": { +// "responses": { +// "200": { +// "content": { +// "application/json": { +// "schema": { +// "$ref": "external_api.yaml#/User" +// } +// } +// } +// } +// } +// } +// } +// } +// } +// +// After bundling (with BundleNamingFilePath): +// +// { +// "openapi": "3.1.0", +// "paths": { +// "/users": { +// "get": { +// "responses": { +// "200": { +// "content": { +// "application/json": { +// "schema": { +// "$ref": "#/components/schemas/external_api_yaml~User" +// } +// } +// } +// } +// } +// } +// } +// }, +// "components": { +// "schemas": { +// "external_api_yaml~User": { +// "type": "object", +// "properties": { +// "id": {"type": "string"}, +// "name": {"type": "string"} +// } +// } +// } +// } +// } +// +// Parameters: +// - ctx: Context for cancellation and timeout control +// - doc: The OpenAPI document to bundle (modified in place) +// - opts: Configuration options for bundling +// +// Returns: +// - error: Any error that occurred during bundling +func Bundle(ctx context.Context, doc *OpenAPI, opts BundleOptions) error { + if doc == nil { + return nil + } + + // Track external references and their new component names + externalRefs := make(map[string]string) // original ref -> new component name + componentSchemas := sequencedmap.New[string, *oas3.JSONSchema[oas3.Referenceable]]() + componentNames := make(map[string]bool) // track used names to avoid conflicts + schemaHashes := make(map[string]string) // component name -> hash for conflict detection + + // Initialize existing component names and hashes to avoid conflicts + if doc.Components != nil && doc.Components.Schemas != nil { + for name, schema := range doc.Components.Schemas.All() { + componentNames[name] = true + if schema != nil { + schemaHashes[name] = hashing.Hash(schema) + } + } + } + + // First pass: collect all external references and resolve them + for item := range Walk(ctx, doc) { + err := item.Match(Matcher{ + Schema: func(schema *oas3.JSONSchema[oas3.Referenceable]) error { + return bundleSchema(ctx, schema, opts, externalRefs, componentSchemas, componentNames, schemaHashes) + }, + ReferencedPathItem: func(ref *ReferencedPathItem) error { + return bundleReference(ctx, ref, opts, externalRefs, componentNames) + }, + ReferencedParameter: func(ref *ReferencedParameter) error { + return bundleReference(ctx, ref, opts, externalRefs, componentNames) + }, + ReferencedExample: func(ref *ReferencedExample) error { + return bundleReference(ctx, ref, opts, externalRefs, componentNames) + }, + ReferencedRequestBody: func(ref *ReferencedRequestBody) error { + return bundleReference(ctx, ref, opts, externalRefs, componentNames) + }, + ReferencedResponse: func(ref *ReferencedResponse) error { + return bundleReference(ctx, ref, opts, externalRefs, componentNames) + }, + ReferencedHeader: func(ref *ReferencedHeader) error { + return bundleReference(ctx, ref, opts, externalRefs, componentNames) + }, + ReferencedCallback: func(ref *ReferencedCallback) error { + return bundleReference(ctx, ref, opts, externalRefs, componentNames) + }, + ReferencedLink: func(ref *ReferencedLink) error { + return bundleReference(ctx, ref, opts, externalRefs, componentNames) + }, + ReferencedSecurityScheme: func(ref *ReferencedSecurityScheme) error { + return bundleReference(ctx, ref, opts, externalRefs, componentNames) + }, + }) + if err != nil { + return fmt.Errorf("failed to bundle item at %s: %w", item.Location.ToJSONPointer().String(), err) + } + } + + // Rewrite references within bundled schemas to handle circular references + err := rewriteRefsInBundledSchemas(ctx, componentSchemas, externalRefs) + if err != nil { + return fmt.Errorf("failed to rewrite references in bundled schemas: %w", err) + } + + // Second pass: update all references to point to new component names + err = updateReferencesToComponents(ctx, doc, externalRefs) + if err != nil { + return fmt.Errorf("failed to update references: %w", err) + } + + // Add collected schemas to components + addSchemasToComponents(doc, componentSchemas) + + return nil +} + +// bundleSchema handles bundling of JSON schemas with external references +func bundleSchema(ctx context.Context, schema *oas3.JSONSchema[oas3.Referenceable], opts BundleOptions, externalRefs map[string]string, componentSchemas *sequencedmap.Map[string, *oas3.JSONSchema[oas3.Referenceable]], componentNames map[string]bool, schemaHashes map[string]string) error { + if !schema.IsReference() { + return nil + } + + ref := string(schema.GetRef()) + + // Check if this is an external reference using the utility function + classification, err := utils.ClassifyReference(ref) + if err != nil || classification.IsFragment { + //nolint:nilerr + return nil // Internal reference or invalid, skip + } + + // Check if we've already processed this reference + if _, exists := externalRefs[ref]; exists { + return nil + } + + // Resolve the external reference + resolveOpts := oas3.ResolveOptions{ + RootDocument: opts.ResolveOptions.RootDocument, + TargetLocation: opts.ResolveOptions.TargetLocation, + } + + if _, err := schema.Resolve(ctx, resolveOpts); err != nil { + return fmt.Errorf("failed to resolve external schema reference %s: %w", ref, err) + } + + // Get the resolved schema + resolvedSchema := schema.GetResolvedSchema() + if resolvedSchema == nil { + return fmt.Errorf("failed to get resolved schema for reference %s", ref) + } + + // Convert back to referenceable schema for storage + resolvedRefSchema := (*oas3.JSONSchema[oas3.Referenceable])(resolvedSchema) + + // Hash the resolved schema for conflict detection + resolvedHash := hashing.Hash(resolvedRefSchema) + + // Generate component name with smart conflict resolution + componentName := generateComponentNameWithHashConflictResolution(ref, opts.NamingStrategy, componentNames, schemaHashes, resolvedHash) + + // Only add to componentSchemas if it's a new schema (not a duplicate) + if _, exists := schemaHashes[componentName]; !exists { + componentNames[componentName] = true + schemaHashes[componentName] = resolvedHash + componentSchemas.Set(componentName, resolvedRefSchema) + + // Recursively process any external references within this resolved schema + err = processNestedExternalReferences(ctx, resolvedRefSchema, opts, externalRefs, componentSchemas, componentNames, schemaHashes) + if err != nil { + return fmt.Errorf("failed to process nested references in %s: %w", ref, err) + } + } + + // Store the mapping + externalRefs[ref] = componentName + + return nil +} + +// processNestedExternalReferences recursively processes external references within a resolved schema +func processNestedExternalReferences(ctx context.Context, schema *oas3.JSONSchema[oas3.Referenceable], opts BundleOptions, externalRefs map[string]string, componentSchemas *sequencedmap.Map[string, *oas3.JSONSchema[oas3.Referenceable]], componentNames map[string]bool, schemaHashes map[string]string) error { + if schema == nil { + return nil + } + + // Walk through the schema to find any external references + for item := range oas3.Walk(ctx, schema) { + err := item.Match(oas3.SchemaMatcher{ + Schema: func(nestedSchema *oas3.JSONSchema[oas3.Referenceable]) error { + // First, process the nested schema to bundle any external references + err := bundleSchema(ctx, nestedSchema, opts, externalRefs, componentSchemas, componentNames, schemaHashes) + if err != nil { + return err + } + + // Only process nested external references during the first pass + // Reference updating will be handled in the second pass + if nestedSchema.IsReference() { + // Just process the nested schema to bundle any external references + // Don't update references here - that will be done in the second pass + err := bundleSchema(ctx, nestedSchema, opts, externalRefs, componentSchemas, componentNames, schemaHashes) + if err != nil { + return err + } + } + + return nil + }, + }) + if err != nil { + return fmt.Errorf("failed to process nested schema: %w", err) + } + } + + return nil +} + +// rewriteRefsInBundledSchemas rewrites references within bundled schemas to point to their new component locations +func rewriteRefsInBundledSchemas(ctx context.Context, componentSchemas *sequencedmap.Map[string, *oas3.JSONSchema[oas3.Referenceable]], externalRefs map[string]string) error { + // Walk through each bundled schema and rewrite internal references + for _, schema := range componentSchemas.All() { + err := rewriteRefsInSchema(ctx, schema, externalRefs) + if err != nil { + return err + } + } + return nil +} + +// rewriteRefsInSchema rewrites references within a single schema +func rewriteRefsInSchema(ctx context.Context, schema *oas3.JSONSchema[oas3.Referenceable], externalRefs map[string]string) error { + if schema == nil { + return nil + } + + // Walk through the schema and rewrite references + for item := range oas3.Walk(ctx, schema) { + err := item.Match(oas3.SchemaMatcher{ + Schema: func(s *oas3.JSONSchema[oas3.Referenceable]) error { + schemaObj := s.GetLeft() + if schemaObj != nil && schemaObj.Ref != nil { + refStr := schemaObj.Ref.String() + + // Check for direct external reference match + if newName, exists := externalRefs[refStr]; exists { + newRef := "#/components/schemas/" + newName + *schemaObj.Ref = references.Reference(newRef) + } else if strings.HasPrefix(refStr, "#/") && !strings.HasPrefix(refStr, "#/components/") { + // Handle circular references within external schemas + // e.g., "#/User" should be mapped to "#/components/schemas/User_1" + defName := strings.TrimPrefix(refStr, "#/") + for externalRef, componentName := range externalRefs { + // Check if the external reference ends with this fragment + // e.g., "external_conflicting_user.yaml#/User" ends with "#/User" + if strings.HasSuffix(externalRef, "#/"+defName) { + newRef := "#/components/schemas/" + componentName + *schemaObj.Ref = references.Reference(newRef) + break + } + } + } + } + return nil + }, + }) + if err != nil { + return fmt.Errorf("failed to rewrite reference in schema: %w", err) + } + } + return nil +} + +// bundleReference handles bundling of generic OpenAPI component references +func bundleReference[T any, V interfaces.Validator[T], C marshaller.CoreModeler](ctx context.Context, ref *Reference[T, V, C], opts BundleOptions, externalRefs map[string]string, componentNames map[string]bool) error { + if ref == nil || !ref.IsReference() { + return nil + } + + refValue := ref.GetReference() + refStr := string(refValue) + + // Check if this is an external reference using the utility function + classification, err := utils.ClassifyReference(refStr) + if err != nil || classification.IsFragment { + //nolint:nilerr + return nil // Internal reference or invalid, skip + } + + // Check if we've already processed this reference + if _, exists := externalRefs[refStr]; exists { + return nil + } + + // For OpenAPI component references, we need to resolve and bring the content into components + // This is simpler than schema references as they don't have circular reference concerns + resolveOpts := ResolveOptions{ + RootDocument: opts.ResolveOptions.RootDocument, + TargetLocation: opts.ResolveOptions.TargetLocation, + } + + _, resolveErr := ref.Resolve(ctx, resolveOpts) + if resolveErr != nil { + return fmt.Errorf("failed to resolve external reference %s: %w", refStr, resolveErr) + } + + // Generate component name + componentName := generateComponentName(refStr, opts.NamingStrategy, componentNames) + componentNames[componentName] = true + + // Store the mapping - for OpenAPI references, we'll handle them in the second pass + externalRefs[refStr] = componentName + + // Note: For non-schema references, we don't store them in componentSchemas + // as they will be handled differently based on their type + + return nil +} + +// generateComponentName creates a new component name based on the reference and naming strategy +func generateComponentName(ref string, strategy BundleNamingStrategy, usedNames map[string]bool) string { + switch strategy { + case BundleNamingFilePath: + return generateFilePathBasedNameWithConflictResolution(ref, usedNames) + case BundleNamingCounter: + return generateCounterBasedName(ref, usedNames) + default: + return generateCounterBasedName(ref, usedNames) + } +} + +// generateComponentNameWithHashConflictResolution creates a component name with smart conflict resolution based on content hashes +func generateComponentNameWithHashConflictResolution(ref string, strategy BundleNamingStrategy, usedNames map[string]bool, schemaHashes map[string]string, resolvedHash string) string { + // Parse the reference to extract the simple name + parts := strings.Split(ref, "#") + if len(parts) == 0 { + parts = []string{ref} // Fallback, though this should never happen + } + fragment := "" + if len(parts) > 1 { + fragment = parts[1] + } + + var simpleName string + if fragment == "" || fragment == "/" { + // Top-level file reference - use filename as simple name + filePath := parts[0] + baseName := filepath.Base(filePath) + ext := filepath.Ext(baseName) + if ext != "" { + baseName = baseName[:len(baseName)-len(ext)] + } + simpleName = regexp.MustCompile(`[^a-zA-Z0-9_]`).ReplaceAllString(baseName, "_") + } else { + // Reference to specific schema within file - extract schema name + cleanFragment := strings.TrimPrefix(fragment, "/") + fragmentParts := strings.Split(cleanFragment, "/") + if len(fragmentParts) == 0 { + // This should never happen as strings.Split never returns nil or empty slice + simpleName = "unknown" + } else { + simpleName = fragmentParts[len(fragmentParts)-1] + } + } + + // Check if a schema with this simple name already exists + if existingHash, exists := schemaHashes[simpleName]; exists { + if existingHash == resolvedHash { + // Same content, reuse existing schema + return simpleName + } + // Different content with same name - need conflict resolution + // Fall back to the configured naming strategy for conflict resolution + return generateComponentName(ref, strategy, usedNames) + } + + // No conflict, use simple name + return simpleName +} + +// generateFilePathBasedNameWithConflictResolution tries to use simple names first, falling back to file-path-based names for conflicts +func generateFilePathBasedNameWithConflictResolution(ref string, usedNames map[string]bool) string { + // Parse the reference to extract file path and fragment + parts := strings.Split(ref, "#") + if len(parts) == 0 { + // This should never happen as strings.Split never returns nil or empty slice + return "unknown" + } + fragment := "" + if len(parts) > 1 { + fragment = parts[1] + } + + var simpleName string + if fragment == "" || fragment == "/" { + // Top-level file reference - use filename as simple name + filePath := parts[0] + baseName := filepath.Base(filePath) + ext := filepath.Ext(baseName) + if ext != "" { + baseName = baseName[:len(baseName)-len(ext)] + } + simpleName = regexp.MustCompile(`[^a-zA-Z0-9_]`).ReplaceAllString(baseName, "_") + } else { + // Reference to specific schema within file - extract schema name + cleanFragment := strings.TrimPrefix(fragment, "/") + fragmentParts := strings.Split(cleanFragment, "/") + if len(fragmentParts) == 0 { + // This should never happen as strings.Split never returns nil or empty slice + simpleName = "unknown" + } else { + simpleName = fragmentParts[len(fragmentParts)-1] + } + } + + // Try simple name first + if !usedNames[simpleName] { + return simpleName + } + + // If there's a conflict, fall back to file-path-based naming + return generateFilePathBasedName(ref, usedNames) +} + +// generateFilePathBasedName creates names like "some_path_external_yaml~User" or "some_path_external_yaml" for top-level refs +func generateFilePathBasedName(ref string, usedNames map[string]bool) string { + // Parse the reference to extract file path and fragment + parts := strings.Split(ref, "#") + if len(parts) == 0 { + // This should never happen as strings.Split never returns nil or empty slice + return "unknown" + } + filePath := parts[0] + fragment := "" + if len(parts) > 1 { + fragment = parts[1] + } + + // Convert full file path to safe component name + // Clean the path but keep extension for uniqueness + cleanPath := filepath.Clean(filePath) + + // Remove leading "./" if present + cleanPath = strings.TrimPrefix(cleanPath, "./") + + // Replace extension dot with underscore to keep it but make it safe + ext := filepath.Ext(cleanPath) + if ext != "" { + cleanPath = cleanPath[:len(cleanPath)-len(ext)] + "_" + ext[1:] // Remove dot, add underscore + } + + // Replace path separators and unsafe characters with underscores + safeFileName := regexp.MustCompile(`[^a-zA-Z0-9_]`).ReplaceAllString(cleanPath, "_") + + var componentName string + if fragment == "" || fragment == "/" { + // Top-level file reference + componentName = safeFileName + } else { + // Reference to specific schema within file + // Clean up fragment (remove leading slash and convert path separators) + cleanFragment := strings.TrimPrefix(fragment, "/") + cleanFragment = strings.ReplaceAll(cleanFragment, "/", "_") + componentName = safeFileName + "~" + cleanFragment + } + + // Ensure uniqueness + originalName := componentName + counter := 1 + for usedNames[componentName] { + componentName = fmt.Sprintf("%s_%d", originalName, counter) + counter++ + } + + return componentName +} + +// generateCounterBasedName creates names like "User_1", "User_2" for conflicts +func generateCounterBasedName(ref string, usedNames map[string]bool) string { + // Extract the schema name from the reference + parts := strings.Split(ref, "#") + if len(parts) == 0 { + // This should never happen as strings.Split never returns nil or empty slice + return "unknown" + } + fragment := "" + if len(parts) > 1 { + fragment = parts[1] + } + + var baseName string + if fragment == "" || fragment == "/" { + // Top-level file reference - use filename + filePath := parts[0] + baseName = filepath.Base(filePath) + ext := filepath.Ext(baseName) + if ext != "" { + baseName = baseName[:len(baseName)-len(ext)] + } + // Replace unsafe characters + baseName = regexp.MustCompile(`[^a-zA-Z0-9_]`).ReplaceAllString(baseName, "_") + } else { + // Extract last part of fragment as schema name + fragmentParts := strings.Split(strings.TrimPrefix(fragment, "/"), "/") + if len(fragmentParts) == 0 { + // This should never happen as strings.Split never returns nil or empty slice + baseName = "unknown" + } else { + baseName = fragmentParts[len(fragmentParts)-1] + } + } + + // Ensure uniqueness with counter + componentName := baseName + counter := 1 + for usedNames[componentName] { + componentName = fmt.Sprintf("%s_%d", baseName, counter) + counter++ + } + + return componentName +} + +// updateReferencesToComponents updates all references in the document to point to new component names +func updateReferencesToComponents(ctx context.Context, doc *OpenAPI, externalRefs map[string]string) error { + for item := range Walk(ctx, doc) { + err := item.Match(Matcher{ + Schema: func(schema *oas3.JSONSchema[oas3.Referenceable]) error { + if schema.IsReference() { + ref := string(schema.GetRef()) + if newName, exists := externalRefs[ref]; exists { + // Update the reference to point to the new component + newRef := "#/components/schemas/" + newName + *schema.GetLeft().Ref = references.Reference(newRef) + } else if strings.HasPrefix(ref, "#/") && !strings.HasPrefix(ref, "#/components/") { + // Handle circular references within external schemas + // Look for a matching external reference that ends with this fragment + for externalRef, componentName := range externalRefs { + // Check if the external reference ends with this fragment + // e.g., "external_conflicting_user.yaml#/User" ends with "#/User" + if strings.HasSuffix(externalRef, ref) { + // Update the circular reference to point to the bundled component + newRef := "#/components/schemas/" + componentName + *schema.GetLeft().Ref = references.Reference(newRef) + break + } + } + } + } + return nil + }, + ReferencedPathItem: func(ref *ReferencedPathItem) error { + return updateReference(ref, externalRefs, "pathItems") + }, + ReferencedParameter: func(ref *ReferencedParameter) error { + return updateReference(ref, externalRefs, "parameters") + }, + ReferencedExample: func(ref *ReferencedExample) error { + return updateReference(ref, externalRefs, "examples") + }, + ReferencedRequestBody: func(ref *ReferencedRequestBody) error { + return updateReference(ref, externalRefs, "requestBodies") + }, + ReferencedResponse: func(ref *ReferencedResponse) error { + return updateReference(ref, externalRefs, "responses") + }, + ReferencedHeader: func(ref *ReferencedHeader) error { + return updateReference(ref, externalRefs, "headers") + }, + ReferencedCallback: func(ref *ReferencedCallback) error { + return updateReference(ref, externalRefs, "callbacks") + }, + ReferencedLink: func(ref *ReferencedLink) error { + return updateReference(ref, externalRefs, "links") + }, + ReferencedSecurityScheme: func(ref *ReferencedSecurityScheme) error { + return updateReference(ref, externalRefs, "securitySchemes") + }, + }) + if err != nil { + return fmt.Errorf("failed to update reference at %s: %w", item.Location.ToJSONPointer().String(), err) + } + } + return nil +} + +// updateReference updates a generic reference to point to the new component name +func updateReference[T any, V interfaces.Validator[T], C marshaller.CoreModeler](ref *Reference[T, V, C], externalRefs map[string]string, componentSection string) error { + if ref == nil || !ref.IsReference() { + return nil + } + + refStr := string(ref.GetReference()) + if newName, exists := externalRefs[refStr]; exists { + // Update the reference to point to the new component + newRef := "#/components/" + componentSection + "/" + newName + *ref.Reference = references.Reference(newRef) + } + return nil +} + +// addSchemasToComponents adds the collected schemas to the document's components section +func addSchemasToComponents(doc *OpenAPI, componentSchemas *sequencedmap.Map[string, *oas3.JSONSchema[oas3.Referenceable]]) { + if componentSchemas.Len() == 0 { + return + } + + // Ensure components section exists + if doc.Components == nil { + doc.Components = &Components{} + } + if doc.Components.Schemas == nil { + doc.Components.Schemas = sequencedmap.New[string, *oas3.JSONSchema[oas3.Referenceable]]() + } + + // Add all collected schemas in insertion order + for name, schema := range componentSchemas.All() { + doc.Components.Schemas.Set(name, schema) + } +} diff --git a/openapi/bundle_test.go b/openapi/bundle_test.go new file mode 100644 index 0000000..448755b --- /dev/null +++ b/openapi/bundle_test.go @@ -0,0 +1,141 @@ +package openapi_test + +import ( + "bytes" + "os" + "testing" + + "github.com/speakeasy-api/openapi/openapi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBundle_Success(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + // Load the input document + inputFile, err := os.Open("testdata/inline/inline_input.yaml") + require.NoError(t, err) + defer inputFile.Close() + + inputDoc, validationErrs, err := openapi.Unmarshal(ctx, inputFile) + require.NoError(t, err) + require.Empty(t, validationErrs, "Input document should be valid") + + // Configure bundling options + opts := openapi.BundleOptions{ + ResolveOptions: openapi.ResolveOptions{ + RootDocument: inputDoc, + TargetLocation: "testdata/inline/inline_input.yaml", + }, + NamingStrategy: openapi.BundleNamingFilePath, + } + + // Bundle all external references + err = openapi.Bundle(ctx, inputDoc, opts) + require.NoError(t, err) + + // Marshal the bundled document to YAML + var buf bytes.Buffer + err = openapi.Marshal(ctx, inputDoc, &buf) + require.NoError(t, err) + actualYAML := buf.Bytes() + + // Save the current output for comparison + err = os.WriteFile("testdata/inline/bundled_current.yaml", actualYAML, 0644) + require.NoError(t, err) + + // Load the expected output + expectedBytes, err := os.ReadFile("testdata/inline/bundled_expected.yaml") + require.NoError(t, err) + + // Compare the actual output with expected output + assert.Equal(t, string(expectedBytes), string(actualYAML), "Bundled document should match expected output") +} + +func TestBundle_CounterNaming_Success(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + // Load the input document + inputFile, err := os.Open("testdata/inline/inline_input.yaml") + require.NoError(t, err) + defer inputFile.Close() + + inputDoc, validationErrs, err := openapi.Unmarshal(ctx, inputFile) + require.NoError(t, err) + require.Empty(t, validationErrs, "Input document should be valid") + + // Configure bundling options with counter naming + opts := openapi.BundleOptions{ + ResolveOptions: openapi.ResolveOptions{ + RootDocument: inputDoc, + TargetLocation: "testdata/inline/inline_input.yaml", + }, + NamingStrategy: openapi.BundleNamingCounter, + } + + // Bundle all external references + err = openapi.Bundle(ctx, inputDoc, opts) + require.NoError(t, err) + + // Marshal the bundled document to YAML + var buf bytes.Buffer + err = openapi.Marshal(ctx, inputDoc, &buf) + require.NoError(t, err) + actualYAML := buf.Bytes() + + // Save the current output for comparison + err = os.WriteFile("testdata/inline/bundled_counter_current.yaml", actualYAML, 0644) + require.NoError(t, err) + + // Load the expected output + expectedBytes, err := os.ReadFile("testdata/inline/bundled_counter_expected.yaml") + require.NoError(t, err) + + // Compare the actual output with expected output + assert.Equal(t, string(expectedBytes), string(actualYAML), "Bundled document with counter naming should match expected output") +} + +func TestBundle_EmptyDocument(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + // Test with nil document + err := openapi.Bundle(ctx, nil, openapi.BundleOptions{}) + require.NoError(t, err) + + // Test with minimal document + doc := &openapi.OpenAPI{ + OpenAPI: "3.1.0", + Info: openapi.Info{ + Title: "Empty API", + Version: "1.0.0", + }, + } + + opts := openapi.BundleOptions{ + ResolveOptions: openapi.ResolveOptions{ + RootDocument: doc, + TargetLocation: "test.yaml", + }, + NamingStrategy: openapi.BundleNamingFilePath, + } + + err = openapi.Bundle(ctx, doc, opts) + require.NoError(t, err) + + // Document should remain unchanged + assert.Equal(t, "3.1.0", doc.OpenAPI) + assert.Equal(t, "Empty API", doc.Info.Title) + assert.Equal(t, "1.0.0", doc.Info.Version) + + // No components should be added + if doc.Components != nil && doc.Components.Schemas != nil { + assert.Equal(t, 0, doc.Components.Schemas.Len(), "No schemas should be added for document without external references") + } +} diff --git a/openapi/cmd/README.md b/openapi/cmd/README.md new file mode 100644 index 0000000..78e6fd5 --- /dev/null +++ b/openapi/cmd/README.md @@ -0,0 +1,230 @@ +# OpenAPI Commands + +Commands for working with OpenAPI specifications. + +OpenAPI specifications define REST APIs in a standard format. These commands help you validate, transform, and work with OpenAPI documents. + +## Available Commands + +### `validate` + +Validate an OpenAPI specification document for compliance with the OpenAPI Specification. + +```bash +# Validate a specification file +openapi openapi validate ./spec.yaml + +# Validate with verbose output +openapi openapi validate -v ./spec.yaml +``` + +This command checks for: + +- Structural validity according to the OpenAPI Specification +- Schema compliance and consistency +- Reference resolution and validity +- Best practice recommendations + +### `upgrade` + +Upgrade an OpenAPI specification to the latest supported version (3.1.1). + +```bash +# Upgrade to stdout +openapi openapi upgrade ./spec.yaml + +# Upgrade to specific file +openapi openapi upgrade ./spec.yaml ./upgraded-spec.yaml + +# Upgrade in-place +openapi openapi upgrade -w ./spec.yaml + +# Upgrade with specific target version +openapi openapi upgrade --version 3.1.0 ./spec.yaml +``` + +Features: + +- Converts OpenAPI 3.0.x specifications to 3.1.x +- Maintains backward compatibility where possible +- Updates schema formats and structures +- Preserves all custom extensions and vendor-specific content + +### `inline` + +Inline all references in an OpenAPI specification to create a self-contained document. + +```bash +# Inline to stdout (pipe-friendly) +openapi openapi inline ./spec-with-refs.yaml + +# Inline to specific file +openapi openapi inline ./spec.yaml ./inlined-spec.yaml + +# Inline in-place +openapi openapi inline -w ./spec.yaml +``` + +What inlining does: + +- Replaces all `$ref` references with their actual content +- Creates a completely self-contained document +- Removes unused components after inlining +- Handles circular references using JSON Schema `$defs` + +**Before inlining:** + +```yaml +paths: + /users: + get: + responses: + '200': + $ref: "#/components/responses/UserResponse" +components: + responses: + UserResponse: + description: User response + content: + application/json: + schema: + $ref: "#/components/schemas/User" +``` + +**After inlining:** + +```yaml +paths: + /users: + get: + responses: + '200': + description: User response + content: + application/json: + schema: + type: object + properties: + name: + type: string +# components section removed (unused after inlining) +``` + +### `bundle` + +Bundle external references into the components section while preserving the reference structure. + +```bash +# Bundle to stdout (pipe-friendly) +openapi openapi bundle ./spec-with-refs.yaml + +# Bundle to specific file with filepath naming (default) +openapi openapi bundle ./spec.yaml ./bundled-spec.yaml + +# Bundle in-place with counter naming +openapi openapi bundle -w --naming counter ./spec.yaml + +# Bundle with filepath naming (explicit) +openapi openapi bundle --naming filepath ./spec.yaml ./bundled.yaml +``` + +**Naming Strategies:** + +- `filepath` (default): Uses file path-based naming like `external_api_yaml~User` for conflicts +- `counter`: Uses counter-based suffixes like `User_1`, `User_2` for conflicts + +What bundling does: + +- Brings all external references into the components section +- Maintains reference structure (unlike inline which expands everything) +- Creates self-contained documents that work with reference-aware tooling +- Handles circular references and naming conflicts intelligently + +**Before bundling:** + +```yaml +paths: + /users: + get: + responses: + '200': + content: + application/json: + schema: + $ref: "external_api.yaml#/User" +``` + +**After bundling:** + +```yaml +paths: + /users: + get: + responses: + '200': + content: + application/json: + schema: + $ref: "#/components/schemas/User" +components: + schemas: + User: + type: object + properties: + id: {type: string} + name: {type: string} +``` + +## Bundle vs Inline + +**Use Bundle when:** + +- You want a self-contained document but need to preserve references +- Your tooling works better with references than expanded content +- You want to maintain the logical structure of your API definition +- You need to prepare documents for further processing + +**Use Inline when:** + +- You want a completely expanded document with no references +- Your tooling doesn't support references well +- You want the simplest possible document structure +- You're creating documentation or examples + +## Common Options + +All commands support these common options: + +- `-h, --help`: Show help for the command +- `-v, --verbose`: Enable verbose output (global flag) +- `-w, --write`: Write output back to the input file (where applicable) + +## Output Formats + +All commands work with both YAML and JSON input files and preserve the original format in the output. When writing to stdout (for piping), the output is optimized to be clean and parseable. + +## Examples + +### Validation Workflow + +```bash +# Validate before processing +openapi openapi validate ./spec.yaml + +# Upgrade if needed +openapi openapi upgrade ./spec.yaml ./spec-v3.1.yaml + +# Bundle external references +openapi openapi bundle ./spec-v3.1.yaml ./spec-bundled.yaml + +# Final validation +openapi openapi validate ./spec-bundled.yaml +``` + +### Processing Pipeline + +```bash +# Create a processing pipeline +openapi openapi bundle ./spec.yaml | \ +openapi openapi upgrade | \ +openapi openapi validate diff --git a/openapi/cmd/bundle.go b/openapi/cmd/bundle.go new file mode 100644 index 0000000..9fb0729 --- /dev/null +++ b/openapi/cmd/bundle.go @@ -0,0 +1,113 @@ +package cmd + +import ( + "context" + "fmt" + + "github.com/speakeasy-api/openapi/openapi" + "github.com/spf13/cobra" +) + +// bundleWriteInPlace controls whether to write the bundled document back to the input file +var bundleWriteInPlace bool + +// bundleNamingStrategy controls the naming strategy for bundled components +var bundleNamingStrategy string + +var bundleCmd = &cobra.Command{ + Use: "bundle [input-file] [output-file]", + Short: "Bundle external references into components section", + Long: `Bundle transforms an OpenAPI document by bringing all external references into the components section, +creating a self-contained document that maintains the reference structure but doesn't depend on external files. + +This operation is useful when you want to: +• Create portable documents that combine multiple OpenAPI files +• Maintain reference structure for tooling that supports references +• Simplify distribution by sharing a single file with all dependencies +• Prepare documents for further processing or transformations + +The bundle command supports two naming strategies: +• counter: Uses counter-based suffixes like User_1, User_2 for conflicts +• filepath: Uses file path-based naming like external_api_yaml~User + +Examples: + # Bundle to stdout (pipe-friendly) + openapi openapi bundle ./spec-with-refs.yaml + + # Bundle to specific file + openapi openapi bundle ./spec.yaml ./bundled-spec.yaml + + # Bundle in-place with counter naming + openapi openapi bundle -w --naming counter ./spec.yaml + + # Bundle with filepath naming (default) + openapi openapi bundle --naming filepath ./spec.yaml ./bundled.yaml`, + Args: cobra.RangeArgs(1, 2), + RunE: runBundleCommand, +} + +func init() { + bundleCmd.Flags().BoolVarP(&bundleWriteInPlace, "write", "w", false, "Write bundled document back to input file") + bundleCmd.Flags().StringVar(&bundleNamingStrategy, "naming", "filepath", "Naming strategy for bundled components (counter|filepath)") +} + +func runBundleCommand(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + // Parse arguments + inputFile := args[0] + var outputFile string + if len(args) > 1 { + outputFile = args[1] + } + + // Validate naming strategy + var namingStrategy openapi.BundleNamingStrategy + switch bundleNamingStrategy { + case "counter": + namingStrategy = openapi.BundleNamingCounter + case "filepath": + namingStrategy = openapi.BundleNamingFilePath + default: + return fmt.Errorf("invalid naming strategy: %s (must be 'counter' or 'filepath')", bundleNamingStrategy) + } + + // Create processor + processor, err := NewOpenAPIProcessor(inputFile, outputFile, bundleWriteInPlace) + if err != nil { + return err + } + + // Load document + doc, validationErrors, err := processor.LoadDocument(ctx) + if err != nil { + return err + } + + // Report validation errors (if any) + processor.ReportValidationErrors(validationErrors) + + // Configure bundle options + opts := openapi.BundleOptions{ + ResolveOptions: openapi.ResolveOptions{ + RootDocument: doc, + TargetLocation: inputFile, + }, + NamingStrategy: namingStrategy, + } + + // Bundle the document + if err := openapi.Bundle(ctx, doc, opts); err != nil { + return fmt.Errorf("failed to bundle document: %w", err) + } + + // Print success message + processor.PrintSuccess("Successfully bundled all external references into components section") + + // Write the bundled document + if err := processor.WriteDocument(ctx, doc); err != nil { + return err + } + + return nil +} diff --git a/openapi/cmd/inline.go b/openapi/cmd/inline.go new file mode 100644 index 0000000..a66653f --- /dev/null +++ b/openapi/cmd/inline.go @@ -0,0 +1,108 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/speakeasy-api/openapi/openapi" + "github.com/spf13/cobra" +) + +var inlineCmd = &cobra.Command{ + Use: "inline [output-file]", + Short: "Inline all references in an OpenAPI specification", + Long: `Inline all $ref references in an OpenAPI specification, creating a self-contained document. + +This command transforms an OpenAPI document by replacing all $ref references with their actual content, +eliminating the need for external definitions or component references. + +Benefits of inlining: +- Create standalone OpenAPI documents for easy distribution +- Improve compatibility with tools that work better with fully expanded specifications +- Provide complete, self-contained documents to AI systems and analysis tools +- Generate documentation where all schemas and components are visible inline +- Eliminate reference resolution overhead in performance-critical applications +- Debug API issues by seeing the full expanded document + +The inlining process: +1. Resolves all component references (#/components/schemas/User, etc.) +2. Replaces $ref with the actual schema/response/parameter content +3. Handles circular references by using JSON Schema $defs +4. Optionally removes unused components after inlining + +Output options: +- No output file specified: writes to stdout (pipe-friendly) +- Output file specified: writes to the specified file +- --write flag: writes in-place to the input file`, + Args: cobra.RangeArgs(1, 2), + Run: runInline, +} + +var ( + inlineWriteInPlace bool +) + +func init() { + inlineCmd.Flags().BoolVarP(&inlineWriteInPlace, "write", "w", false, "write result in-place to input file") +} + +func runInline(cmd *cobra.Command, args []string) { + ctx := cmd.Context() + inputFile := args[0] + + var outputFile string + if len(args) > 1 { + outputFile = args[1] + } + + processor, err := NewOpenAPIProcessor(inputFile, outputFile, inlineWriteInPlace) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + if err := inlineOpenAPI(ctx, processor); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} + +func inlineOpenAPI(ctx context.Context, processor *OpenAPIProcessor) error { + // Load the OpenAPI document + doc, validationErrors, err := processor.LoadDocument(ctx) + if err != nil { + return err + } + if doc == nil { + return errors.New("failed to parse OpenAPI document: document is nil") + } + + // Report validation errors but continue with inlining + processor.ReportValidationErrors(validationErrors) + + // Prepare inline options (always remove unused components) + opts := openapi.InlineOptions{ + ResolveOptions: openapi.ResolveOptions{ + RootDocument: doc, + TargetLocation: filepath.Clean(processor.InputFile), + }, + RemoveUnusedComponents: true, + } + + // Perform the inlining + if err := openapi.Inline(ctx, doc, opts); err != nil { + return fmt.Errorf("failed to inline OpenAPI document: %w", err) + } + + processor.PrintSuccess("Successfully inlined all references and removed unused components") + + return processor.WriteDocument(ctx, doc) +} + +// GetInlineCommand returns the inline command for external use +func GetInlineCommand() *cobra.Command { + return inlineCmd +} diff --git a/openapi/cmd/root.go b/openapi/cmd/root.go index 8d51795..8d9b406 100644 --- a/openapi/cmd/root.go +++ b/openapi/cmd/root.go @@ -6,4 +6,6 @@ import "github.com/spf13/cobra" func Apply(rootCmd *cobra.Command) { rootCmd.AddCommand(validateCmd) rootCmd.AddCommand(upgradeCmd) + rootCmd.AddCommand(inlineCmd) + rootCmd.AddCommand(bundleCmd) } diff --git a/openapi/cmd/shared.go b/openapi/cmd/shared.go new file mode 100644 index 0000000..e059a20 --- /dev/null +++ b/openapi/cmd/shared.go @@ -0,0 +1,116 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/speakeasy-api/openapi/marshaller" + "github.com/speakeasy-api/openapi/openapi" +) + +// OpenAPIProcessor handles common OpenAPI document processing operations +type OpenAPIProcessor struct { + InputFile string + OutputFile string + WriteToStdout bool +} + +// NewOpenAPIProcessor creates a new processor with the given input and output files +func NewOpenAPIProcessor(inputFile, outputFile string, writeInPlace bool) (*OpenAPIProcessor, error) { + var finalOutputFile string + + if writeInPlace { + if outputFile != "" { + return nil, errors.New("cannot specify output file when using --write flag") + } + finalOutputFile = inputFile + } else { + finalOutputFile = outputFile + } + + return &OpenAPIProcessor{ + InputFile: inputFile, + OutputFile: finalOutputFile, + WriteToStdout: finalOutputFile == "", + }, nil +} + +// LoadDocument loads and parses an OpenAPI document from the input file +func (p *OpenAPIProcessor) LoadDocument(ctx context.Context) (*openapi.OpenAPI, []error, error) { + cleanInputFile := filepath.Clean(p.InputFile) + + // Only print status messages if not writing to stdout (to keep stdout clean for piping) + if !p.WriteToStdout { + fmt.Printf("Processing OpenAPI document: %s\n", cleanInputFile) + } + + // Read the input file + f, err := os.Open(cleanInputFile) + if err != nil { + return nil, nil, fmt.Errorf("failed to open input file: %w", err) + } + defer f.Close() + + // Parse the OpenAPI document + doc, validationErrors, err := openapi.Unmarshal(ctx, f) + if err != nil { + return nil, nil, fmt.Errorf("failed to unmarshal OpenAPI document: %w", err) + } + if doc == nil { + return nil, nil, errors.New("failed to parse OpenAPI document: document is nil") + } + + return doc, validationErrors, nil +} + +// ReportValidationErrors reports validation errors if not writing to stdout +func (p *OpenAPIProcessor) ReportValidationErrors(validationErrors []error) { + if len(validationErrors) > 0 && !p.WriteToStdout { + fmt.Printf("⚠️ Found %d validation errors in original document:\n", len(validationErrors)) + for i, validationErr := range validationErrors { + fmt.Printf(" %d. %s\n", i+1, validationErr.Error()) + } + fmt.Println() + } +} + +// WriteDocument writes the processed document to the output destination +func (p *OpenAPIProcessor) WriteDocument(ctx context.Context, doc *openapi.OpenAPI) error { + if p.WriteToStdout { + // Write to stdout (pipe-friendly) + return marshaller.Marshal(ctx, doc, os.Stdout) + } + + // Write to file + cleanOutputFile := filepath.Clean(p.OutputFile) + outFile, err := os.Create(cleanOutputFile) + if err != nil { + return fmt.Errorf("failed to create output file: %w", err) + } + defer outFile.Close() + + if err := marshaller.Marshal(ctx, doc, outFile); err != nil { + return fmt.Errorf("failed to write document: %w", err) + } + + fmt.Printf("📄 Document written to: %s\n", cleanOutputFile) + + return nil +} + +// PrintSuccess prints a success message if not writing to stdout +func (p *OpenAPIProcessor) PrintSuccess(message string) { + if !p.WriteToStdout { + fmt.Printf("✅ %s\n", message) + } +} + +// PrintInfo prints an info message if not writing to stdout +func (p *OpenAPIProcessor) PrintInfo(message string) { + if !p.WriteToStdout { + fmt.Printf("📋 %s\n", message) + } +} diff --git a/openapi/cmd/upgrade.go b/openapi/cmd/upgrade.go index 43e7382..fb30251 100644 --- a/openapi/cmd/upgrade.go +++ b/openapi/cmd/upgrade.go @@ -5,9 +5,7 @@ import ( "errors" "fmt" "os" - "path/filepath" - "github.com/speakeasy-api/openapi/marshaller" "github.com/speakeasy-api/openapi/openapi" "github.com/spf13/cobra" ) @@ -51,55 +49,34 @@ func runUpgrade(cmd *cobra.Command, args []string) { inputFile := args[0] var outputFile string - if writeInPlace { - if len(args) > 1 { - fmt.Fprintf(os.Stderr, "Error: cannot specify output file when using --write flag\n") - os.Exit(1) - } - outputFile = inputFile - } else if len(args) > 1 { + if len(args) > 1 { outputFile = args[1] } - if err := upgradeOpenAPI(ctx, inputFile, outputFile, minorOnly); err != nil { + processor, err := NewOpenAPIProcessor(inputFile, outputFile, writeInPlace) + if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } -} - -func upgradeOpenAPI(ctx context.Context, inputFile, outputFile string, minorOnly bool) error { - cleanInputFile := filepath.Clean(inputFile) - - // Only print status messages if not writing to stdout (to keep stdout clean for piping) - writeToStdout := outputFile == "" - if !writeToStdout { - fmt.Printf("Upgrading OpenAPI document: %s\n", cleanInputFile) - } - // Read the input file - f, err := os.Open(cleanInputFile) - if err != nil { - return fmt.Errorf("failed to open input file: %w", err) + if err := upgradeOpenAPI(ctx, processor, minorOnly); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) } - defer f.Close() +} - // Parse the OpenAPI document - doc, validationErrors, err := openapi.Unmarshal(ctx, f) +func upgradeOpenAPI(ctx context.Context, processor *OpenAPIProcessor, minorOnly bool) error { + // Load the OpenAPI document + doc, validationErrors, err := processor.LoadDocument(ctx) if err != nil { - return fmt.Errorf("failed to unmarshal OpenAPI document: %w", err) + return err } if doc == nil { return errors.New("failed to parse OpenAPI document: document is nil") } - // Report validation errors but continue with upgrade (only if not writing to stdout) - if len(validationErrors) > 0 && !writeToStdout { - fmt.Printf("⚠️ Found %d validation errors in original document:\n", len(validationErrors)) - for i, validationErr := range validationErrors { - fmt.Printf(" %d. %s\n", i+1, validationErr.Error()) - } - fmt.Println() - } + // Report validation errors but continue with upgrade + processor.ReportValidationErrors(validationErrors) // Prepare upgrade options var opts []openapi.Option[openapi.UpgradeOptions] @@ -118,43 +95,14 @@ func upgradeOpenAPI(ctx context.Context, inputFile, outputFile string, minorOnly } if !upgraded { - if !writeToStdout { - fmt.Printf("📋 No upgrade needed - document is already at version %s\n", originalVersion) - } + processor.PrintInfo("No upgrade needed - document is already at version " + originalVersion) // Still output the document even if no upgrade was needed - return writeOutput(ctx, doc, outputFile, writeToStdout) - } - - if !writeToStdout { - fmt.Printf("✅ Successfully upgraded from %s to %s\n", originalVersion, doc.OpenAPI) - } - - return writeOutput(ctx, doc, outputFile, writeToStdout) -} - -func writeOutput(ctx context.Context, doc *openapi.OpenAPI, outputFile string, writeToStdout bool) error { - if writeToStdout { - // Write to stdout (pipe-friendly) - return marshaller.Marshal(ctx, doc, os.Stdout) + return processor.WriteDocument(ctx, doc) } - // Write to file - cleanOutputFile := filepath.Clean(outputFile) - outFile, err := os.Create(cleanOutputFile) - if err != nil { - return fmt.Errorf("failed to create output file: %w", err) - } - defer outFile.Close() - - if err := marshaller.Marshal(ctx, doc, outFile); err != nil { - return fmt.Errorf("failed to write upgraded document: %w", err) - } - - if cleanOutputFile == filepath.Clean(outputFile) { - fmt.Printf("📄 Upgraded document written to: %s\n", cleanOutputFile) - } + processor.PrintSuccess(fmt.Sprintf("Successfully upgraded from %s to %s", originalVersion, doc.OpenAPI)) - return nil + return processor.WriteDocument(ctx, doc) } // GetUpgradeCommand returns the upgrade command for external use diff --git a/openapi/core/reference.go b/openapi/core/reference.go index 09daadb..fecbc90 100644 --- a/openapi/core/reference.go +++ b/openapi/core/reference.go @@ -66,22 +66,65 @@ func (r *Reference[T]) SyncChanges(ctx context.Context, model any, valueNode *ya } of := mv.FieldByName("Object") - if of.IsZero() { + rf := mv.FieldByName("Reference") + + // Check if we have an object but no reference (inlined case) + hasObject := !of.IsZero() + hasReference := !rf.IsZero() && !rf.IsNil() + + if hasObject && !hasReference { + // Inlined case: we have an object but no reference + // Clear the reference in the core model and sync only the object + r.Reference = marshaller.Node[*string]{} + var err error - valueNode, err = marshaller.SyncValue(ctx, model, r, valueNode, true) + valueNode, err = marshaller.SyncValue(ctx, of.Interface(), &r.Object, valueNode, false) if err != nil { return nil, err } - r.SetValid(true, true) + + // Also manually remove $ref from the YAML node if it exists + if valueNode != nil && valueNode.Kind == yaml.MappingNode { + // Remove $ref key-value pair from the mapping node + newContent := make([]*yaml.Node, 0, len(valueNode.Content)) + for i := 0; i < len(valueNode.Content); i += 2 { + if i+1 < len(valueNode.Content) && valueNode.Content[i].Value != "$ref" { + newContent = append(newContent, valueNode.Content[i], valueNode.Content[i+1]) + } + } + valueNode.Content = newContent + } + + // We are valid if the object is valid + r.SetValid(r.Object.GetValid(), r.Object.GetValidYaml()) } else { + // Reference case: we have a reference but no object + // Clear the object and sync the reference + var zero T + r.Object = zero + var err error - valueNode, err = marshaller.SyncValue(ctx, of.Interface(), &r.Object, valueNode, false) + valueNode, err = marshaller.SyncValue(ctx, model, r, valueNode, true) if err != nil { return nil, err } - // We are valid if the object is valid - r.SetValid(r.Object.GetValid(), r.Object.GetValidYaml()) + // Also manually remove object fields from the YAML node if it exists + // Keep only $ref, summary, and description fields + if valueNode != nil && valueNode.Kind == yaml.MappingNode { + newContent := make([]*yaml.Node, 0, len(valueNode.Content)) + for i := 0; i < len(valueNode.Content); i += 2 { + if i+1 < len(valueNode.Content) { + key := valueNode.Content[i].Value + if key == "$ref" || key == "summary" || key == "description" { + newContent = append(newContent, valueNode.Content[i], valueNode.Content[i+1]) + } + } + } + valueNode.Content = newContent + } + + r.SetValid(true, true) } r.SetRootNode(valueNode) diff --git a/openapi/inline.go b/openapi/inline.go new file mode 100644 index 0000000..b76c3f7 --- /dev/null +++ b/openapi/inline.go @@ -0,0 +1,402 @@ +package openapi + +import ( + "context" + "fmt" + "strings" + + "github.com/speakeasy-api/openapi/hashing" + "github.com/speakeasy-api/openapi/internal/interfaces" + "github.com/speakeasy-api/openapi/jsonschema/oas3" + "github.com/speakeasy-api/openapi/marshaller" + "github.com/speakeasy-api/openapi/references" + "github.com/speakeasy-api/openapi/sequencedmap" +) + +// InlineOptions represents the options available when inlining an OpenAPI document. +type InlineOptions struct { + // ResolveOptions are the options to use when resolving references during inlining. + ResolveOptions ResolveOptions + // RemoveUnusedComponents determines whether to remove components that are no longer referenced after inlining. + RemoveUnusedComponents bool +} + +// Inline transforms an OpenAPI document by replacing all $ref references with their actual content, +// creating a self-contained document that doesn't depend on external definitions or component references. +// This operation modifies the document in place. +// +// Why use Inline? +// +// - **Simplify document distribution**: Create standalone OpenAPI documents that can be shared without worrying +// about missing referenced files or component definitions +// - **AI and tooling integration**: Provide complete, self-contained OpenAPI documents to AI systems and +// tools that work better with fully expanded specifications +// - **Improve compatibility**: Some tools work better with fully expanded documents rather +// than ones with references +// - **Generate documentation**: Create complete API representations for documentation +// where all schemas and components are visible inline +// - **Optimize for specific use cases**: Eliminate the need for reference resolution in +// performance-critical applications +// - **Debug API issues**: See the full expanded document to understand how references resolve +// +// What you'll get: +// +// Before inlining: +// +// { +// "openapi": "3.1.0", +// "paths": { +// "/users": { +// "get": { +// "responses": { +// "200": {"$ref": "#/components/responses/UserResponse"} +// } +// } +// } +// }, +// "components": { +// "responses": { +// "UserResponse": { +// "description": "User response", +// "content": { +// "application/json": { +// "schema": {"$ref": "#/components/schemas/User"} +// } +// } +// } +// }, +// "schemas": { +// "User": {"type": "object", "properties": {"name": {"type": "string"}}} +// } +// } +// } +// +// After inlining: +// +// { +// "openapi": "3.1.0", +// "paths": { +// "/users": { +// "get": { +// "responses": { +// "200": { +// "description": "User response", +// "content": { +// "application/json": { +// "schema": {"type": "object", "properties": {"name": {"type": "string"}}} +// } +// } +// } +// } +// } +// } +// } +// } +// +// Handling References: +// +// Unlike JSON Schema references, OpenAPI component references are simpler to handle since they don't +// typically have circular reference issues. The function will: +// +// 1. Walk through the entire OpenAPI document +// 2. For each reference encountered, resolve and inline it in place +// 3. For JSON schemas, use the existing oas3.Inline functionality +// 4. Optionally remove unused components after inlining +// +// Example usage: +// +// // Load an OpenAPI document with references +// doc := &OpenAPI{...} +// +// // Configure inlining +// opts := InlineOptions{ +// ResolveOptions: ResolveOptions{ +// RootDocument: doc, +// TargetLocation: "openapi.yaml", +// }, +// RemoveUnusedComponents: true, // Clean up unused components +// } +// +// // Inline all references (modifies doc in place) +// err := Inline(ctx, doc, opts) +// if err != nil { +// return fmt.Errorf("failed to inline document: %w", err) +// } +// +// // doc is now a self-contained OpenAPI document with all references expanded +// +// Parameters: +// - ctx: Context for the operation +// - doc: The OpenAPI document to inline (modified in place) +// - opts: Configuration options for inlining +// +// Returns: +// - error: Any error that occurred during inlining +func Inline(ctx context.Context, doc *OpenAPI, opts InlineOptions) error { + if doc == nil { + return nil + } + + inlinedSchemas := map[*oas3.JSONSchema[oas3.Referenceable]]*oas3.JSONSchema[oas3.Referenceable]{} + + // Track collected $defs to avoid duplication + collectedDefs := make(map[string]*oas3.JSONSchema[oas3.Referenceable]) + defHashes := make(map[string]string) // name -> hash + + for item := range Walk(ctx, doc) { + err := item.Match(Matcher{ + Schema: func(schema *oas3.JSONSchema[oas3.Referenceable]) error { + location := item.Location.ToJSONPointer().String() + + // Only skip top-level component schema definitions (e.g., /components/schemas/User) + // but allow inlining of schemas that reference them (e.g., /components/responses/UserResponse/content/application~1json/schema) + if strings.HasPrefix(location, "/components") { + // Split the path to check if this is a top-level schema definition + parts := strings.Split(location, "/") + // parts[0] is empty, parts[1] is "components", parts[2] is "schemas" or some other section, parts[3] is the schema name + // If we have exactly 4 parts, this is a top-level schema definition like /components/schemas/User + if len(parts) == 4 { + return nil + } + } + + parent := item.Location[len(item.Location)-1].Parent + + parentIsSchema := false + _ = parent(Matcher{ + Schema: func(parentSchema *oas3.JSONSchema[oas3.Referenceable]) error { + parentIsSchema = true + return nil + }, + }) + // If the parent is a schema, we don't need to inline it + if parentIsSchema { + return nil + } + + inlineOpts := oas3.InlineOptions{ + ResolveOptions: oas3.ResolveOptions{ + RootDocument: doc, + TargetLocation: opts.ResolveOptions.TargetLocation, + }, + RemoveUnusedDefs: true, + } + + inlined, err := oas3.Inline(ctx, schema, inlineOpts) + if err != nil { + return fmt.Errorf("failed to inline schema: %w", err) + } + + inlinedSchemas[schema] = inlined + + return nil + }, + }) + if err != nil { + return fmt.Errorf("failed to inline schemas: %w", err) + } + } + + // Walk through the document and inline all references + for item := range Walk(ctx, doc) { + err := item.Match(Matcher{ + // Handle JSON Schema references using the existing oas3.Inline functionality + Schema: func(schema *oas3.JSONSchema[oas3.Referenceable]) error { + inlined, exists := inlinedSchemas[schema] + if !exists { + return nil + } + + // Process $defs from the inlined schema before replacement + inlinedSchema := inlined.GetLeft() + if inlinedSchema != nil && inlinedSchema.Defs != nil && inlinedSchema.Defs.Len() > 0 { + // Ensure components/schemas exists + if doc.Components == nil { + doc.Components = &Components{} + } + if doc.Components.Schemas == nil { + doc.Components.Schemas = sequencedmap.New[string, *oas3.JSONSchema[oas3.Referenceable]]() + } + + // Process each $def and build a mapping for this specific schema + nameMapping := make(map[string]string) + for defName, defSchema := range inlinedSchema.Defs.All() { + targetName := defName + defHash := hashing.Hash(defSchema) + + // Check for conflicts + if existingHash, exists := defHashes[defName]; exists { + if existingHash != defHash { + // Different schema with same name - add suffix + counter := 1 + for { + candidateName := fmt.Sprintf("%s_%d", defName, counter) + if existingHash, exists := defHashes[candidateName]; !exists || existingHash == defHash { + targetName = candidateName + break + } + counter++ + } + } + } + + // Store the mapping for this schema + nameMapping[defName] = targetName + + // Store the schema if it's new + if _, exists := defHashes[targetName]; !exists { + defHashes[targetName] = defHash + collectedDefs[targetName] = defSchema + doc.Components.Schemas.Set(targetName, defSchema) + } + } + + // Rewrite $refs in the inlined schema to point to components/schemas + rewriteRefsWithMapping(inlined, nameMapping) + + // Remove $defs from the inlined schema + inlinedSchema.Defs = nil + } + + // Replace the schema in place + *schema = *inlined + return nil + }, + + // Handle OpenAPI component references + ReferencedPathItem: func(ref *ReferencedPathItem) error { + return inlineReference(ctx, ref, opts) + }, + ReferencedParameter: func(ref *ReferencedParameter) error { + return inlineReference(ctx, ref, opts) + }, + ReferencedExample: func(ref *ReferencedExample) error { + return inlineReference(ctx, ref, opts) + }, + ReferencedRequestBody: func(ref *ReferencedRequestBody) error { + return inlineReference(ctx, ref, opts) + }, + ReferencedResponse: func(ref *ReferencedResponse) error { + return inlineReference(ctx, ref, opts) + }, + ReferencedHeader: func(ref *ReferencedHeader) error { + return inlineReference(ctx, ref, opts) + }, + ReferencedCallback: func(ref *ReferencedCallback) error { + return inlineReference(ctx, ref, opts) + }, + ReferencedLink: func(ref *ReferencedLink) error { + return inlineReference(ctx, ref, opts) + }, + ReferencedSecurityScheme: func(ref *ReferencedSecurityScheme) error { + return inlineReference(ctx, ref, opts) + }, + }) + + if err != nil { + return fmt.Errorf("failed to inline references: %w", err) + } + } + + // Remove unused components if requested + if opts.RemoveUnusedComponents { + removeUnusedComponents(doc, collectedDefs) + } + + return nil +} + +// inlineReference inlines a generic OpenAPI reference by resolving it and replacing the reference with the actual object +func inlineReference[T any, V interfaces.Validator[T], C marshaller.CoreModeler](ctx context.Context, ref *Reference[T, V, C], opts InlineOptions) error { + if ref == nil || !ref.IsReference() { + return nil + } + + // Resolve the reference + validationErrs, err := ref.Resolve(ctx, opts.ResolveOptions) + if err != nil { + return fmt.Errorf("failed to resolve reference %s: %w", ref.GetReference(), err) + } + + // Log validation errors but don't fail on them + if len(validationErrs) > 0 { + // In a production system, you might want to log these or handle them differently + // For now, we'll continue with the inlining process + _ = validationErrs // Acknowledge we're intentionally ignoring these + } + + // Get the resolved object + obj := ref.GetObject() + if obj == nil { + return fmt.Errorf("reference %s resolved to nil object", ref.GetReference()) + } + + // Replace the reference with the actual object in place + ref.Reference = nil + ref.Object = obj + ref.Summary = nil + ref.Description = nil + + return nil +} + +// rewriteRefsWithMapping uses the Walk API to rewrite $ref paths from $defs to components/schemas +// using a specific name mapping for this schema +func rewriteRefsWithMapping(schema *oas3.JSONSchema[oas3.Referenceable], nameMapping map[string]string) { + if schema == nil { + return + } + + // Walk through all schemas and rewrite references + for item := range oas3.Walk(context.Background(), schema) { + err := item.Match(oas3.SchemaMatcher{ + Schema: func(s *oas3.JSONSchema[oas3.Referenceable]) error { + schemaObj := s.GetLeft() + if schemaObj != nil && schemaObj.Ref != nil { + refStr := schemaObj.Ref.String() + if strings.HasPrefix(refStr, "#/$defs/") { + defName := strings.TrimPrefix(refStr, "#/$defs/") + // Use the specific mapping for this schema + if targetName, exists := nameMapping[defName]; exists { + newRef := "#/components/schemas/" + targetName + *schemaObj.Ref = references.Reference(newRef) + } + } else if strings.HasPrefix(refStr, "#/") { + // Handle external file references like "#/User" + defName := strings.TrimPrefix(refStr, "#/") + if targetName, exists := nameMapping[defName]; exists { + newRef := "#/components/schemas/" + targetName + *schemaObj.Ref = references.Reference(newRef) + } + } + } + return nil + }, + }) + if err != nil { + // Log error but continue processing + _ = err + } + } +} + +// removeUnusedComponents removes components that are no longer referenced after inlining +func removeUnusedComponents(doc *OpenAPI, preserveSchemas map[string]*oas3.JSONSchema[oas3.Referenceable]) { + if doc == nil || doc.Components == nil { + return + } + + // Create new components with only the schemas we moved from $defs + if len(preserveSchemas) > 0 { + newSchemas := sequencedmap.New[string, *oas3.JSONSchema[oas3.Referenceable]]() + for name, schema := range preserveSchemas { + newSchemas.Set(name, schema) + } + doc.Components = &Components{ + Schemas: newSchemas, + } + } else { + // No schemas to preserve, clear all components + doc.Components = nil + } +} diff --git a/openapi/inline_test.go b/openapi/inline_test.go new file mode 100644 index 0000000..69a94d6 --- /dev/null +++ b/openapi/inline_test.go @@ -0,0 +1,85 @@ +package openapi_test + +import ( + "bytes" + "os" + "testing" + + "github.com/speakeasy-api/openapi/openapi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInline_Success(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + // Load the input document + inputFile, err := os.Open("testdata/inline/inline_input.yaml") + require.NoError(t, err) + defer inputFile.Close() + + inputDoc, validationErrs, err := openapi.Unmarshal(ctx, inputFile) + require.NoError(t, err) + require.Empty(t, validationErrs, "Input document should be valid") + + // Configure inlining options + opts := openapi.InlineOptions{ + ResolveOptions: openapi.ResolveOptions{ + RootDocument: inputDoc, + TargetLocation: "testdata/inline/inline_input.yaml", + }, + RemoveUnusedComponents: true, + } + + // Inline all references + err = openapi.Inline(ctx, inputDoc, opts) + require.NoError(t, err) + + // Marshal the inlined document to YAML + var buf bytes.Buffer + err = openapi.Marshal(ctx, inputDoc, &buf) + require.NoError(t, err) + actualYAML := buf.Bytes() + + // Save the current output for comparison + err = os.WriteFile("testdata/inline/inline_current.yaml", actualYAML, 0644) + require.NoError(t, err) + + // Load the expected output + expectedBytes, err := os.ReadFile("testdata/inline/inline_expected.yaml") + require.NoError(t, err) + + // Compare the actual output with expected output + assert.Equal(t, string(expectedBytes), string(actualYAML), "Inlined document should match expected output") +} + +func TestInline_EmptyDocument(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + // Test with nil document + err := openapi.Inline(ctx, nil, openapi.InlineOptions{}) + require.NoError(t, err) + + // Test with minimal document + doc := &openapi.OpenAPI{ + OpenAPI: "3.1.0", + Info: openapi.Info{ + Title: "Empty API", + Version: "1.0.0", + }, + } + + opts := openapi.InlineOptions{ + ResolveOptions: openapi.ResolveOptions{ + RootDocument: doc, + TargetLocation: "test.yaml", + }, + } + + err = openapi.Inline(ctx, doc, opts) + assert.NoError(t, err) +} diff --git a/openapi/reference_inline_test.go b/openapi/reference_inline_test.go new file mode 100644 index 0000000..6a2ab98 --- /dev/null +++ b/openapi/reference_inline_test.go @@ -0,0 +1,56 @@ +package openapi + +import ( + "bytes" + "testing" + + "github.com/speakeasy-api/openapi/marshaller" + "github.com/speakeasy-api/openapi/pointer" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestReference_InlinedSerialization_Success(t *testing.T) { + t.Parallel() + + // Start with YAML that has a reference + yamlWithRef := `$ref: '#/components/parameters/UserIdParam'` + + // Unmarshal the reference + var ref ReferencedParameter + validationErrs, err := marshaller.Unmarshal(t.Context(), bytes.NewBufferString(yamlWithRef), &ref) + require.NoError(t, err) + assert.Empty(t, validationErrs) + + // Verify it's a reference + assert.True(t, ref.IsReference(), "Should be a reference after unmarshaling") + assert.Equal(t, "#/components/parameters/UserIdParam", string(ref.GetReference())) + + // Create the object to inline + param := &Parameter{ + Name: "userId", + In: ParameterInPath, + Required: pointer.From(true), + Description: pointer.From("User ID parameter"), + } + + // Inline the reference by setting the object and clearing the reference + ref.Object = param + ref.Reference = nil + + // Marshal to YAML + var buf bytes.Buffer + err = marshaller.Marshal(t.Context(), &ref, &buf) + require.NoError(t, err) + + yamlStr := buf.String() + + // Expected YAML should only contain the object properties, not the $ref + expectedYAML := `name: 'userId' +in: 'path' +description: 'User ID parameter' +required: true +` + + assert.Equal(t, expectedYAML, yamlStr, "Inlined reference should serialize only object properties") +} diff --git a/openapi/testdata/inline/bundled_counter_current.yaml b/openapi/testdata/inline/bundled_counter_current.yaml new file mode 100644 index 0000000..859b2a2 --- /dev/null +++ b/openapi/testdata/inline/bundled_counter_current.yaml @@ -0,0 +1,661 @@ +openapi: 3.1.0 +info: + title: Enhanced Test API with Complex References + version: 1.0.0 + description: A comprehensive test document with circular references, external refs, and duplicate schemas + contact: + name: Test Contact + url: https://example.com + license: + name: MIT + url: https://opensource.org/licenses/MIT +servers: + - url: https://api.example.com/v1 + description: Production server +tags: + - name: users + description: User operations + - name: posts + description: Post operations + - name: organizations + description: Organization operations + - name: external + description: External reference operations +paths: + /users: + get: + tags: + - users + summary: List users + parameters: + - $ref: "#/components/parameters/LimitParam" + - $ref: "#/components/parameters/OffsetParam" + responses: + "200": + $ref: "#/components/responses/UserListResponse" + "400": + $ref: "#/components/responses/ErrorResponse" + post: + tags: + - users + summary: Create user + requestBody: + $ref: "#/components/requestBodies/CreateUserRequest" + responses: + "201": + $ref: "#/components/responses/UserResponse" + "400": + $ref: "#/components/responses/ErrorResponse" + /users/{id}: + parameters: + - $ref: "#/components/parameters/UserIdParam" + get: + tags: + - users + summary: Get user by ID + responses: + "200": + $ref: "#/components/responses/UserResponse" + "404": + $ref: "#/components/responses/ErrorResponse" + put: + tags: + - users + summary: Update user + requestBody: + $ref: "#/components/requestBodies/UpdateUserRequest" + responses: + "200": + $ref: "#/components/responses/UserResponse" + "404": + $ref: "#/components/responses/ErrorResponse" + /external-users: + get: + tags: + - users + summary: Get external users (uses conflicting User schema) + responses: + "200": + description: List of external users + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/User_1" + /posts: + get: + tags: + - posts + summary: List posts + responses: + "200": + description: List of posts + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Post" + examples: + posts_example: + $ref: "#/components/examples/PostsExample" + post: + tags: + - posts + summary: Create post (uses circular Post schema) + requestBody: + description: Post creation data + required: true + content: + application/json: + schema: + type: object + required: [title, content, author_id] + properties: + title: + type: string + minLength: 1 + maxLength: 200 + content: + type: string + minLength: 1 + author_id: + type: string + format: uuid + tags: + type: array + items: + type: string + responses: + "201": + description: Created post + content: + application/json: + schema: + $ref: "#/components/schemas/Post" + # Additional operations using the same circular reference schemas + /users/{id}/posts: + parameters: + - $ref: "#/components/parameters/UserIdParam" + get: + tags: [posts] + summary: Get user posts (uses circular Post schema) + responses: + "200": + description: List of user posts + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Post" + "404": + $ref: "#/components/responses/ErrorResponse" + /posts/{id}: + parameters: + - name: id + in: path + required: true + description: Post ID + schema: + type: string + format: uuid + get: + tags: [posts] + summary: Get post by ID (uses circular Post schema) + responses: + "200": + description: Single post + content: + application/json: + schema: + $ref: "#/components/schemas/Post" + "404": + $ref: "#/components/responses/ErrorResponse" + # External reference operations + /external/users: + get: + tags: [external] + summary: Get external users (external ref to same User schema) + responses: + "200": + description: External users + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/User" + /external/organizations: + get: + tags: [external] + summary: Get organizations (external ref to different schema) + responses: + "200": + description: Organizations + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Organization" + /external/simple: + get: + tags: [external] + summary: Get simple external data + responses: + "200": + description: Simple external data + content: + application/json: + schema: + $ref: "#/components/schemas/external_simple_schema" + /mixed/user-with-external-profile: + get: + tags: [external] + summary: Mixed internal/external references + responses: + "200": + description: User with external profile + content: + application/json: + schema: + type: object + properties: + user: + $ref: "#/components/schemas/User" + external_profile: + $ref: "#/components/schemas/external_user_profile" + simple_data: + $ref: "#/components/schemas/external_simple_schema" + # More operations to test $defs duplication + /users/search: + get: + tags: [users] + summary: Search users (another operation using User schema) + parameters: + - name: q + in: query + description: Search query + schema: + type: string + responses: + "200": + description: Search results + content: + application/json: + schema: + type: object + properties: + results: + type: array + items: + $ref: "#/components/schemas/User" + total: + type: integer + /posts/search: + get: + tags: [posts] + summary: Search posts (another operation using Post schema) + parameters: + - name: q + in: query + description: Search query + schema: + type: string + responses: + "200": + description: Search results + content: + application/json: + schema: + type: object + properties: + results: + type: array + items: + $ref: "#/components/schemas/Post" + total: + type: integer +components: + parameters: + LimitParam: + name: limit + in: query + description: Maximum number of items to return + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 + OffsetParam: + name: offset + in: query + description: Number of items to skip + schema: + type: integer + minimum: 0 + default: 0 + UserIdParam: + name: id + in: path + required: true + description: User ID + schema: + type: string + format: uuid + schemas: + User: + type: object + required: + - id + - name + - email + properties: + id: + type: string + format: uuid + description: Unique user identifier + name: + type: string + minLength: 1 + maxLength: 100 + description: User's full name + email: + type: string + format: email + description: User's email address + profile: + $ref: "#/components/schemas/UserProfile" + posts: + type: array + items: + $ref: "#/components/schemas/Post" + description: Posts created by this user + UserProfile: + type: object + properties: + bio: + type: string + maxLength: 500 + description: User biography + avatar_url: + type: string + format: uri + description: URL to user's avatar image + social_links: + type: object + additionalProperties: + type: string + format: uri + preferences: + $ref: "#/components/schemas/UserPreferences" + UserPreferences: + type: object + properties: + theme: + type: string + enum: [light, dark, auto] + default: auto + notifications: + type: object + properties: + email: + type: boolean + default: true + push: + type: boolean + default: false + Post: + type: object + required: + - id + - title + - content + - author_id + properties: + id: + type: string + format: uuid + title: + type: string + minLength: 1 + maxLength: 200 + content: + type: string + minLength: 1 + author_id: + type: string + format: uuid + author: + $ref: "#/components/schemas/User" + tags: + type: array + items: + type: string + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + # Circular reference example - Tree structure + TreeNode: + type: object + properties: + id: + type: string + name: + type: string + children: + type: array + items: + $ref: "#/components/schemas/TreeNode" + parent: + $ref: "#/components/schemas/TreeNode" + Error: + type: object + required: + - code + - message + properties: + code: + type: string + description: Error code + message: + type: string + description: Error message + details: + type: object + additionalProperties: true + description: Additional error details + User_1: + type: object + properties: + userId: + type: integer + description: Numeric user identifier (conflicts with string id) + username: + type: string + maxLength: 50 + minLength: 3 + description: User's username (conflicts with name field) + email: + type: string + format: email + description: User's email address + role: + type: string + enum: + - admin + - user + - guest + description: User's role in the system + default: user + createdAt: + type: string + format: date-time + description: When the user was created + manager: + $ref: '#/components/schemas/User_1' + description: User's manager (creates circular reference) + required: + - userId + - username + Organization: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + maxLength: 200 + minLength: 1 + description: + type: string + website: + type: string + format: uri + members: + type: array + items: + $ref: '#/components/schemas/User' + required: + - id + - name + external_simple_schema: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + maxLength: 100 + minLength: 1 + description: + type: string + maxLength: 500 + created_at: + type: string + format: date-time + required: + - id + - name + external_user_profile: + type: object + properties: + bio: + type: string + maxLength: 500 + description: User biography + avatar_url: + type: string + format: uri + description: URL to user's avatar image + social_links: + type: object + additionalProperties: + type: string + format: uri + preferences: + $ref: '#/components/schemas/external_user_preferences' + external_user_preferences: + type: object + properties: + theme: + type: string + enum: + - light + - dark + - auto + default: auto + notifications: + type: object + properties: + email: + type: boolean + default: true + push: + type: boolean + default: false + requestBodies: + CreateUserRequest: + description: Request body for creating a user + required: true + content: + application/json: + schema: + type: object + required: + - name + - email + properties: + name: + type: string + minLength: 1 + maxLength: 100 + email: + type: string + format: email + profile: + $ref: "#/components/schemas/UserProfile" + UpdateUserRequest: + description: Request body for updating a user + required: true + content: + application/json: + schema: + type: object + properties: + name: + type: string + minLength: 1 + maxLength: 100 + email: + type: string + format: email + profile: + $ref: "#/components/schemas/UserProfile" + responses: + UserResponse: + description: Single user response + content: + application/json: + schema: + $ref: "#/components/schemas/User" + examples: + user_example: + $ref: "#/components/examples/UserExample" + UserListResponse: + description: List of users response + content: + application/json: + schema: + type: object + properties: + users: + type: array + items: + $ref: "#/components/schemas/User" + total: + type: integer + minimum: 0 + limit: + type: integer + minimum: 1 + offset: + type: integer + minimum: 0 + ErrorResponse: + description: Error response + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + examples: + UserExample: + summary: Example user + description: An example user object + value: + id: "123e4567-e89b-12d3-a456-426614174000" + name: "John Doe" + email: "john.doe@example.com" + profile: + bio: "Software developer" + avatar_url: "https://example.com/avatar.jpg" + social_links: + github: "https://github.com/johndoe" + twitter: "https://twitter.com/johndoe" + preferences: + theme: "dark" + notifications: + email: true + push: false + PostsExample: + summary: Example posts + description: An example list of posts + value: + - id: "456e7890-e89b-12d3-a456-426614174001" + title: "My First Post" + content: "This is my first post content" + author_id: "123e4567-e89b-12d3-a456-426614174000" + tags: ["introduction", "first-post"] + created_at: "2023-01-01T12:00:00Z" + updated_at: "2023-01-01T12:00:00Z" + headers: + X-Rate-Limit: + description: Rate limit information + schema: + type: integer + minimum: 0 + securitySchemes: + BearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: JWT Bearer token authentication +security: + - BearerAuth: [] diff --git a/openapi/testdata/inline/bundled_counter_expected.yaml b/openapi/testdata/inline/bundled_counter_expected.yaml new file mode 100644 index 0000000..859b2a2 --- /dev/null +++ b/openapi/testdata/inline/bundled_counter_expected.yaml @@ -0,0 +1,661 @@ +openapi: 3.1.0 +info: + title: Enhanced Test API with Complex References + version: 1.0.0 + description: A comprehensive test document with circular references, external refs, and duplicate schemas + contact: + name: Test Contact + url: https://example.com + license: + name: MIT + url: https://opensource.org/licenses/MIT +servers: + - url: https://api.example.com/v1 + description: Production server +tags: + - name: users + description: User operations + - name: posts + description: Post operations + - name: organizations + description: Organization operations + - name: external + description: External reference operations +paths: + /users: + get: + tags: + - users + summary: List users + parameters: + - $ref: "#/components/parameters/LimitParam" + - $ref: "#/components/parameters/OffsetParam" + responses: + "200": + $ref: "#/components/responses/UserListResponse" + "400": + $ref: "#/components/responses/ErrorResponse" + post: + tags: + - users + summary: Create user + requestBody: + $ref: "#/components/requestBodies/CreateUserRequest" + responses: + "201": + $ref: "#/components/responses/UserResponse" + "400": + $ref: "#/components/responses/ErrorResponse" + /users/{id}: + parameters: + - $ref: "#/components/parameters/UserIdParam" + get: + tags: + - users + summary: Get user by ID + responses: + "200": + $ref: "#/components/responses/UserResponse" + "404": + $ref: "#/components/responses/ErrorResponse" + put: + tags: + - users + summary: Update user + requestBody: + $ref: "#/components/requestBodies/UpdateUserRequest" + responses: + "200": + $ref: "#/components/responses/UserResponse" + "404": + $ref: "#/components/responses/ErrorResponse" + /external-users: + get: + tags: + - users + summary: Get external users (uses conflicting User schema) + responses: + "200": + description: List of external users + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/User_1" + /posts: + get: + tags: + - posts + summary: List posts + responses: + "200": + description: List of posts + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Post" + examples: + posts_example: + $ref: "#/components/examples/PostsExample" + post: + tags: + - posts + summary: Create post (uses circular Post schema) + requestBody: + description: Post creation data + required: true + content: + application/json: + schema: + type: object + required: [title, content, author_id] + properties: + title: + type: string + minLength: 1 + maxLength: 200 + content: + type: string + minLength: 1 + author_id: + type: string + format: uuid + tags: + type: array + items: + type: string + responses: + "201": + description: Created post + content: + application/json: + schema: + $ref: "#/components/schemas/Post" + # Additional operations using the same circular reference schemas + /users/{id}/posts: + parameters: + - $ref: "#/components/parameters/UserIdParam" + get: + tags: [posts] + summary: Get user posts (uses circular Post schema) + responses: + "200": + description: List of user posts + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Post" + "404": + $ref: "#/components/responses/ErrorResponse" + /posts/{id}: + parameters: + - name: id + in: path + required: true + description: Post ID + schema: + type: string + format: uuid + get: + tags: [posts] + summary: Get post by ID (uses circular Post schema) + responses: + "200": + description: Single post + content: + application/json: + schema: + $ref: "#/components/schemas/Post" + "404": + $ref: "#/components/responses/ErrorResponse" + # External reference operations + /external/users: + get: + tags: [external] + summary: Get external users (external ref to same User schema) + responses: + "200": + description: External users + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/User" + /external/organizations: + get: + tags: [external] + summary: Get organizations (external ref to different schema) + responses: + "200": + description: Organizations + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Organization" + /external/simple: + get: + tags: [external] + summary: Get simple external data + responses: + "200": + description: Simple external data + content: + application/json: + schema: + $ref: "#/components/schemas/external_simple_schema" + /mixed/user-with-external-profile: + get: + tags: [external] + summary: Mixed internal/external references + responses: + "200": + description: User with external profile + content: + application/json: + schema: + type: object + properties: + user: + $ref: "#/components/schemas/User" + external_profile: + $ref: "#/components/schemas/external_user_profile" + simple_data: + $ref: "#/components/schemas/external_simple_schema" + # More operations to test $defs duplication + /users/search: + get: + tags: [users] + summary: Search users (another operation using User schema) + parameters: + - name: q + in: query + description: Search query + schema: + type: string + responses: + "200": + description: Search results + content: + application/json: + schema: + type: object + properties: + results: + type: array + items: + $ref: "#/components/schemas/User" + total: + type: integer + /posts/search: + get: + tags: [posts] + summary: Search posts (another operation using Post schema) + parameters: + - name: q + in: query + description: Search query + schema: + type: string + responses: + "200": + description: Search results + content: + application/json: + schema: + type: object + properties: + results: + type: array + items: + $ref: "#/components/schemas/Post" + total: + type: integer +components: + parameters: + LimitParam: + name: limit + in: query + description: Maximum number of items to return + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 + OffsetParam: + name: offset + in: query + description: Number of items to skip + schema: + type: integer + minimum: 0 + default: 0 + UserIdParam: + name: id + in: path + required: true + description: User ID + schema: + type: string + format: uuid + schemas: + User: + type: object + required: + - id + - name + - email + properties: + id: + type: string + format: uuid + description: Unique user identifier + name: + type: string + minLength: 1 + maxLength: 100 + description: User's full name + email: + type: string + format: email + description: User's email address + profile: + $ref: "#/components/schemas/UserProfile" + posts: + type: array + items: + $ref: "#/components/schemas/Post" + description: Posts created by this user + UserProfile: + type: object + properties: + bio: + type: string + maxLength: 500 + description: User biography + avatar_url: + type: string + format: uri + description: URL to user's avatar image + social_links: + type: object + additionalProperties: + type: string + format: uri + preferences: + $ref: "#/components/schemas/UserPreferences" + UserPreferences: + type: object + properties: + theme: + type: string + enum: [light, dark, auto] + default: auto + notifications: + type: object + properties: + email: + type: boolean + default: true + push: + type: boolean + default: false + Post: + type: object + required: + - id + - title + - content + - author_id + properties: + id: + type: string + format: uuid + title: + type: string + minLength: 1 + maxLength: 200 + content: + type: string + minLength: 1 + author_id: + type: string + format: uuid + author: + $ref: "#/components/schemas/User" + tags: + type: array + items: + type: string + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + # Circular reference example - Tree structure + TreeNode: + type: object + properties: + id: + type: string + name: + type: string + children: + type: array + items: + $ref: "#/components/schemas/TreeNode" + parent: + $ref: "#/components/schemas/TreeNode" + Error: + type: object + required: + - code + - message + properties: + code: + type: string + description: Error code + message: + type: string + description: Error message + details: + type: object + additionalProperties: true + description: Additional error details + User_1: + type: object + properties: + userId: + type: integer + description: Numeric user identifier (conflicts with string id) + username: + type: string + maxLength: 50 + minLength: 3 + description: User's username (conflicts with name field) + email: + type: string + format: email + description: User's email address + role: + type: string + enum: + - admin + - user + - guest + description: User's role in the system + default: user + createdAt: + type: string + format: date-time + description: When the user was created + manager: + $ref: '#/components/schemas/User_1' + description: User's manager (creates circular reference) + required: + - userId + - username + Organization: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + maxLength: 200 + minLength: 1 + description: + type: string + website: + type: string + format: uri + members: + type: array + items: + $ref: '#/components/schemas/User' + required: + - id + - name + external_simple_schema: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + maxLength: 100 + minLength: 1 + description: + type: string + maxLength: 500 + created_at: + type: string + format: date-time + required: + - id + - name + external_user_profile: + type: object + properties: + bio: + type: string + maxLength: 500 + description: User biography + avatar_url: + type: string + format: uri + description: URL to user's avatar image + social_links: + type: object + additionalProperties: + type: string + format: uri + preferences: + $ref: '#/components/schemas/external_user_preferences' + external_user_preferences: + type: object + properties: + theme: + type: string + enum: + - light + - dark + - auto + default: auto + notifications: + type: object + properties: + email: + type: boolean + default: true + push: + type: boolean + default: false + requestBodies: + CreateUserRequest: + description: Request body for creating a user + required: true + content: + application/json: + schema: + type: object + required: + - name + - email + properties: + name: + type: string + minLength: 1 + maxLength: 100 + email: + type: string + format: email + profile: + $ref: "#/components/schemas/UserProfile" + UpdateUserRequest: + description: Request body for updating a user + required: true + content: + application/json: + schema: + type: object + properties: + name: + type: string + minLength: 1 + maxLength: 100 + email: + type: string + format: email + profile: + $ref: "#/components/schemas/UserProfile" + responses: + UserResponse: + description: Single user response + content: + application/json: + schema: + $ref: "#/components/schemas/User" + examples: + user_example: + $ref: "#/components/examples/UserExample" + UserListResponse: + description: List of users response + content: + application/json: + schema: + type: object + properties: + users: + type: array + items: + $ref: "#/components/schemas/User" + total: + type: integer + minimum: 0 + limit: + type: integer + minimum: 1 + offset: + type: integer + minimum: 0 + ErrorResponse: + description: Error response + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + examples: + UserExample: + summary: Example user + description: An example user object + value: + id: "123e4567-e89b-12d3-a456-426614174000" + name: "John Doe" + email: "john.doe@example.com" + profile: + bio: "Software developer" + avatar_url: "https://example.com/avatar.jpg" + social_links: + github: "https://github.com/johndoe" + twitter: "https://twitter.com/johndoe" + preferences: + theme: "dark" + notifications: + email: true + push: false + PostsExample: + summary: Example posts + description: An example list of posts + value: + - id: "456e7890-e89b-12d3-a456-426614174001" + title: "My First Post" + content: "This is my first post content" + author_id: "123e4567-e89b-12d3-a456-426614174000" + tags: ["introduction", "first-post"] + created_at: "2023-01-01T12:00:00Z" + updated_at: "2023-01-01T12:00:00Z" + headers: + X-Rate-Limit: + description: Rate limit information + schema: + type: integer + minimum: 0 + securitySchemes: + BearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: JWT Bearer token authentication +security: + - BearerAuth: [] diff --git a/openapi/testdata/inline/bundled_current.yaml b/openapi/testdata/inline/bundled_current.yaml new file mode 100644 index 0000000..4d0dff2 --- /dev/null +++ b/openapi/testdata/inline/bundled_current.yaml @@ -0,0 +1,661 @@ +openapi: 3.1.0 +info: + title: Enhanced Test API with Complex References + version: 1.0.0 + description: A comprehensive test document with circular references, external refs, and duplicate schemas + contact: + name: Test Contact + url: https://example.com + license: + name: MIT + url: https://opensource.org/licenses/MIT +servers: + - url: https://api.example.com/v1 + description: Production server +tags: + - name: users + description: User operations + - name: posts + description: Post operations + - name: organizations + description: Organization operations + - name: external + description: External reference operations +paths: + /users: + get: + tags: + - users + summary: List users + parameters: + - $ref: "#/components/parameters/LimitParam" + - $ref: "#/components/parameters/OffsetParam" + responses: + "200": + $ref: "#/components/responses/UserListResponse" + "400": + $ref: "#/components/responses/ErrorResponse" + post: + tags: + - users + summary: Create user + requestBody: + $ref: "#/components/requestBodies/CreateUserRequest" + responses: + "201": + $ref: "#/components/responses/UserResponse" + "400": + $ref: "#/components/responses/ErrorResponse" + /users/{id}: + parameters: + - $ref: "#/components/parameters/UserIdParam" + get: + tags: + - users + summary: Get user by ID + responses: + "200": + $ref: "#/components/responses/UserResponse" + "404": + $ref: "#/components/responses/ErrorResponse" + put: + tags: + - users + summary: Update user + requestBody: + $ref: "#/components/requestBodies/UpdateUserRequest" + responses: + "200": + $ref: "#/components/responses/UserResponse" + "404": + $ref: "#/components/responses/ErrorResponse" + /external-users: + get: + tags: + - users + summary: Get external users (uses conflicting User schema) + responses: + "200": + description: List of external users + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/external_conflicting_user_yaml~User" + /posts: + get: + tags: + - posts + summary: List posts + responses: + "200": + description: List of posts + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Post" + examples: + posts_example: + $ref: "#/components/examples/PostsExample" + post: + tags: + - posts + summary: Create post (uses circular Post schema) + requestBody: + description: Post creation data + required: true + content: + application/json: + schema: + type: object + required: [title, content, author_id] + properties: + title: + type: string + minLength: 1 + maxLength: 200 + content: + type: string + minLength: 1 + author_id: + type: string + format: uuid + tags: + type: array + items: + type: string + responses: + "201": + description: Created post + content: + application/json: + schema: + $ref: "#/components/schemas/Post" + # Additional operations using the same circular reference schemas + /users/{id}/posts: + parameters: + - $ref: "#/components/parameters/UserIdParam" + get: + tags: [posts] + summary: Get user posts (uses circular Post schema) + responses: + "200": + description: List of user posts + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Post" + "404": + $ref: "#/components/responses/ErrorResponse" + /posts/{id}: + parameters: + - name: id + in: path + required: true + description: Post ID + schema: + type: string + format: uuid + get: + tags: [posts] + summary: Get post by ID (uses circular Post schema) + responses: + "200": + description: Single post + content: + application/json: + schema: + $ref: "#/components/schemas/Post" + "404": + $ref: "#/components/responses/ErrorResponse" + # External reference operations + /external/users: + get: + tags: [external] + summary: Get external users (external ref to same User schema) + responses: + "200": + description: External users + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/User" + /external/organizations: + get: + tags: [external] + summary: Get organizations (external ref to different schema) + responses: + "200": + description: Organizations + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Organization" + /external/simple: + get: + tags: [external] + summary: Get simple external data + responses: + "200": + description: Simple external data + content: + application/json: + schema: + $ref: "#/components/schemas/external_simple_schema" + /mixed/user-with-external-profile: + get: + tags: [external] + summary: Mixed internal/external references + responses: + "200": + description: User with external profile + content: + application/json: + schema: + type: object + properties: + user: + $ref: "#/components/schemas/User" + external_profile: + $ref: "#/components/schemas/external_user_profile" + simple_data: + $ref: "#/components/schemas/external_simple_schema" + # More operations to test $defs duplication + /users/search: + get: + tags: [users] + summary: Search users (another operation using User schema) + parameters: + - name: q + in: query + description: Search query + schema: + type: string + responses: + "200": + description: Search results + content: + application/json: + schema: + type: object + properties: + results: + type: array + items: + $ref: "#/components/schemas/User" + total: + type: integer + /posts/search: + get: + tags: [posts] + summary: Search posts (another operation using Post schema) + parameters: + - name: q + in: query + description: Search query + schema: + type: string + responses: + "200": + description: Search results + content: + application/json: + schema: + type: object + properties: + results: + type: array + items: + $ref: "#/components/schemas/Post" + total: + type: integer +components: + parameters: + LimitParam: + name: limit + in: query + description: Maximum number of items to return + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 + OffsetParam: + name: offset + in: query + description: Number of items to skip + schema: + type: integer + minimum: 0 + default: 0 + UserIdParam: + name: id + in: path + required: true + description: User ID + schema: + type: string + format: uuid + schemas: + User: + type: object + required: + - id + - name + - email + properties: + id: + type: string + format: uuid + description: Unique user identifier + name: + type: string + minLength: 1 + maxLength: 100 + description: User's full name + email: + type: string + format: email + description: User's email address + profile: + $ref: "#/components/schemas/UserProfile" + posts: + type: array + items: + $ref: "#/components/schemas/Post" + description: Posts created by this user + UserProfile: + type: object + properties: + bio: + type: string + maxLength: 500 + description: User biography + avatar_url: + type: string + format: uri + description: URL to user's avatar image + social_links: + type: object + additionalProperties: + type: string + format: uri + preferences: + $ref: "#/components/schemas/UserPreferences" + UserPreferences: + type: object + properties: + theme: + type: string + enum: [light, dark, auto] + default: auto + notifications: + type: object + properties: + email: + type: boolean + default: true + push: + type: boolean + default: false + Post: + type: object + required: + - id + - title + - content + - author_id + properties: + id: + type: string + format: uuid + title: + type: string + minLength: 1 + maxLength: 200 + content: + type: string + minLength: 1 + author_id: + type: string + format: uuid + author: + $ref: "#/components/schemas/User" + tags: + type: array + items: + type: string + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + # Circular reference example - Tree structure + TreeNode: + type: object + properties: + id: + type: string + name: + type: string + children: + type: array + items: + $ref: "#/components/schemas/TreeNode" + parent: + $ref: "#/components/schemas/TreeNode" + Error: + type: object + required: + - code + - message + properties: + code: + type: string + description: Error code + message: + type: string + description: Error message + details: + type: object + additionalProperties: true + description: Additional error details + external_conflicting_user_yaml~User: + type: object + properties: + userId: + type: integer + description: Numeric user identifier (conflicts with string id) + username: + type: string + maxLength: 50 + minLength: 3 + description: User's username (conflicts with name field) + email: + type: string + format: email + description: User's email address + role: + type: string + enum: + - admin + - user + - guest + description: User's role in the system + default: user + createdAt: + type: string + format: date-time + description: When the user was created + manager: + $ref: '#/components/schemas/external_conflicting_user_yaml~User' + description: User's manager (creates circular reference) + required: + - userId + - username + Organization: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + maxLength: 200 + minLength: 1 + description: + type: string + website: + type: string + format: uri + members: + type: array + items: + $ref: '#/components/schemas/User' + required: + - id + - name + external_simple_schema: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + maxLength: 100 + minLength: 1 + description: + type: string + maxLength: 500 + created_at: + type: string + format: date-time + required: + - id + - name + external_user_profile: + type: object + properties: + bio: + type: string + maxLength: 500 + description: User biography + avatar_url: + type: string + format: uri + description: URL to user's avatar image + social_links: + type: object + additionalProperties: + type: string + format: uri + preferences: + $ref: '#/components/schemas/external_user_preferences' + external_user_preferences: + type: object + properties: + theme: + type: string + enum: + - light + - dark + - auto + default: auto + notifications: + type: object + properties: + email: + type: boolean + default: true + push: + type: boolean + default: false + requestBodies: + CreateUserRequest: + description: Request body for creating a user + required: true + content: + application/json: + schema: + type: object + required: + - name + - email + properties: + name: + type: string + minLength: 1 + maxLength: 100 + email: + type: string + format: email + profile: + $ref: "#/components/schemas/UserProfile" + UpdateUserRequest: + description: Request body for updating a user + required: true + content: + application/json: + schema: + type: object + properties: + name: + type: string + minLength: 1 + maxLength: 100 + email: + type: string + format: email + profile: + $ref: "#/components/schemas/UserProfile" + responses: + UserResponse: + description: Single user response + content: + application/json: + schema: + $ref: "#/components/schemas/User" + examples: + user_example: + $ref: "#/components/examples/UserExample" + UserListResponse: + description: List of users response + content: + application/json: + schema: + type: object + properties: + users: + type: array + items: + $ref: "#/components/schemas/User" + total: + type: integer + minimum: 0 + limit: + type: integer + minimum: 1 + offset: + type: integer + minimum: 0 + ErrorResponse: + description: Error response + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + examples: + UserExample: + summary: Example user + description: An example user object + value: + id: "123e4567-e89b-12d3-a456-426614174000" + name: "John Doe" + email: "john.doe@example.com" + profile: + bio: "Software developer" + avatar_url: "https://example.com/avatar.jpg" + social_links: + github: "https://github.com/johndoe" + twitter: "https://twitter.com/johndoe" + preferences: + theme: "dark" + notifications: + email: true + push: false + PostsExample: + summary: Example posts + description: An example list of posts + value: + - id: "456e7890-e89b-12d3-a456-426614174001" + title: "My First Post" + content: "This is my first post content" + author_id: "123e4567-e89b-12d3-a456-426614174000" + tags: ["introduction", "first-post"] + created_at: "2023-01-01T12:00:00Z" + updated_at: "2023-01-01T12:00:00Z" + headers: + X-Rate-Limit: + description: Rate limit information + schema: + type: integer + minimum: 0 + securitySchemes: + BearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: JWT Bearer token authentication +security: + - BearerAuth: [] diff --git a/openapi/testdata/inline/bundled_expected.yaml b/openapi/testdata/inline/bundled_expected.yaml new file mode 100644 index 0000000..4d0dff2 --- /dev/null +++ b/openapi/testdata/inline/bundled_expected.yaml @@ -0,0 +1,661 @@ +openapi: 3.1.0 +info: + title: Enhanced Test API with Complex References + version: 1.0.0 + description: A comprehensive test document with circular references, external refs, and duplicate schemas + contact: + name: Test Contact + url: https://example.com + license: + name: MIT + url: https://opensource.org/licenses/MIT +servers: + - url: https://api.example.com/v1 + description: Production server +tags: + - name: users + description: User operations + - name: posts + description: Post operations + - name: organizations + description: Organization operations + - name: external + description: External reference operations +paths: + /users: + get: + tags: + - users + summary: List users + parameters: + - $ref: "#/components/parameters/LimitParam" + - $ref: "#/components/parameters/OffsetParam" + responses: + "200": + $ref: "#/components/responses/UserListResponse" + "400": + $ref: "#/components/responses/ErrorResponse" + post: + tags: + - users + summary: Create user + requestBody: + $ref: "#/components/requestBodies/CreateUserRequest" + responses: + "201": + $ref: "#/components/responses/UserResponse" + "400": + $ref: "#/components/responses/ErrorResponse" + /users/{id}: + parameters: + - $ref: "#/components/parameters/UserIdParam" + get: + tags: + - users + summary: Get user by ID + responses: + "200": + $ref: "#/components/responses/UserResponse" + "404": + $ref: "#/components/responses/ErrorResponse" + put: + tags: + - users + summary: Update user + requestBody: + $ref: "#/components/requestBodies/UpdateUserRequest" + responses: + "200": + $ref: "#/components/responses/UserResponse" + "404": + $ref: "#/components/responses/ErrorResponse" + /external-users: + get: + tags: + - users + summary: Get external users (uses conflicting User schema) + responses: + "200": + description: List of external users + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/external_conflicting_user_yaml~User" + /posts: + get: + tags: + - posts + summary: List posts + responses: + "200": + description: List of posts + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Post" + examples: + posts_example: + $ref: "#/components/examples/PostsExample" + post: + tags: + - posts + summary: Create post (uses circular Post schema) + requestBody: + description: Post creation data + required: true + content: + application/json: + schema: + type: object + required: [title, content, author_id] + properties: + title: + type: string + minLength: 1 + maxLength: 200 + content: + type: string + minLength: 1 + author_id: + type: string + format: uuid + tags: + type: array + items: + type: string + responses: + "201": + description: Created post + content: + application/json: + schema: + $ref: "#/components/schemas/Post" + # Additional operations using the same circular reference schemas + /users/{id}/posts: + parameters: + - $ref: "#/components/parameters/UserIdParam" + get: + tags: [posts] + summary: Get user posts (uses circular Post schema) + responses: + "200": + description: List of user posts + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Post" + "404": + $ref: "#/components/responses/ErrorResponse" + /posts/{id}: + parameters: + - name: id + in: path + required: true + description: Post ID + schema: + type: string + format: uuid + get: + tags: [posts] + summary: Get post by ID (uses circular Post schema) + responses: + "200": + description: Single post + content: + application/json: + schema: + $ref: "#/components/schemas/Post" + "404": + $ref: "#/components/responses/ErrorResponse" + # External reference operations + /external/users: + get: + tags: [external] + summary: Get external users (external ref to same User schema) + responses: + "200": + description: External users + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/User" + /external/organizations: + get: + tags: [external] + summary: Get organizations (external ref to different schema) + responses: + "200": + description: Organizations + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Organization" + /external/simple: + get: + tags: [external] + summary: Get simple external data + responses: + "200": + description: Simple external data + content: + application/json: + schema: + $ref: "#/components/schemas/external_simple_schema" + /mixed/user-with-external-profile: + get: + tags: [external] + summary: Mixed internal/external references + responses: + "200": + description: User with external profile + content: + application/json: + schema: + type: object + properties: + user: + $ref: "#/components/schemas/User" + external_profile: + $ref: "#/components/schemas/external_user_profile" + simple_data: + $ref: "#/components/schemas/external_simple_schema" + # More operations to test $defs duplication + /users/search: + get: + tags: [users] + summary: Search users (another operation using User schema) + parameters: + - name: q + in: query + description: Search query + schema: + type: string + responses: + "200": + description: Search results + content: + application/json: + schema: + type: object + properties: + results: + type: array + items: + $ref: "#/components/schemas/User" + total: + type: integer + /posts/search: + get: + tags: [posts] + summary: Search posts (another operation using Post schema) + parameters: + - name: q + in: query + description: Search query + schema: + type: string + responses: + "200": + description: Search results + content: + application/json: + schema: + type: object + properties: + results: + type: array + items: + $ref: "#/components/schemas/Post" + total: + type: integer +components: + parameters: + LimitParam: + name: limit + in: query + description: Maximum number of items to return + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 + OffsetParam: + name: offset + in: query + description: Number of items to skip + schema: + type: integer + minimum: 0 + default: 0 + UserIdParam: + name: id + in: path + required: true + description: User ID + schema: + type: string + format: uuid + schemas: + User: + type: object + required: + - id + - name + - email + properties: + id: + type: string + format: uuid + description: Unique user identifier + name: + type: string + minLength: 1 + maxLength: 100 + description: User's full name + email: + type: string + format: email + description: User's email address + profile: + $ref: "#/components/schemas/UserProfile" + posts: + type: array + items: + $ref: "#/components/schemas/Post" + description: Posts created by this user + UserProfile: + type: object + properties: + bio: + type: string + maxLength: 500 + description: User biography + avatar_url: + type: string + format: uri + description: URL to user's avatar image + social_links: + type: object + additionalProperties: + type: string + format: uri + preferences: + $ref: "#/components/schemas/UserPreferences" + UserPreferences: + type: object + properties: + theme: + type: string + enum: [light, dark, auto] + default: auto + notifications: + type: object + properties: + email: + type: boolean + default: true + push: + type: boolean + default: false + Post: + type: object + required: + - id + - title + - content + - author_id + properties: + id: + type: string + format: uuid + title: + type: string + minLength: 1 + maxLength: 200 + content: + type: string + minLength: 1 + author_id: + type: string + format: uuid + author: + $ref: "#/components/schemas/User" + tags: + type: array + items: + type: string + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + # Circular reference example - Tree structure + TreeNode: + type: object + properties: + id: + type: string + name: + type: string + children: + type: array + items: + $ref: "#/components/schemas/TreeNode" + parent: + $ref: "#/components/schemas/TreeNode" + Error: + type: object + required: + - code + - message + properties: + code: + type: string + description: Error code + message: + type: string + description: Error message + details: + type: object + additionalProperties: true + description: Additional error details + external_conflicting_user_yaml~User: + type: object + properties: + userId: + type: integer + description: Numeric user identifier (conflicts with string id) + username: + type: string + maxLength: 50 + minLength: 3 + description: User's username (conflicts with name field) + email: + type: string + format: email + description: User's email address + role: + type: string + enum: + - admin + - user + - guest + description: User's role in the system + default: user + createdAt: + type: string + format: date-time + description: When the user was created + manager: + $ref: '#/components/schemas/external_conflicting_user_yaml~User' + description: User's manager (creates circular reference) + required: + - userId + - username + Organization: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + maxLength: 200 + minLength: 1 + description: + type: string + website: + type: string + format: uri + members: + type: array + items: + $ref: '#/components/schemas/User' + required: + - id + - name + external_simple_schema: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + maxLength: 100 + minLength: 1 + description: + type: string + maxLength: 500 + created_at: + type: string + format: date-time + required: + - id + - name + external_user_profile: + type: object + properties: + bio: + type: string + maxLength: 500 + description: User biography + avatar_url: + type: string + format: uri + description: URL to user's avatar image + social_links: + type: object + additionalProperties: + type: string + format: uri + preferences: + $ref: '#/components/schemas/external_user_preferences' + external_user_preferences: + type: object + properties: + theme: + type: string + enum: + - light + - dark + - auto + default: auto + notifications: + type: object + properties: + email: + type: boolean + default: true + push: + type: boolean + default: false + requestBodies: + CreateUserRequest: + description: Request body for creating a user + required: true + content: + application/json: + schema: + type: object + required: + - name + - email + properties: + name: + type: string + minLength: 1 + maxLength: 100 + email: + type: string + format: email + profile: + $ref: "#/components/schemas/UserProfile" + UpdateUserRequest: + description: Request body for updating a user + required: true + content: + application/json: + schema: + type: object + properties: + name: + type: string + minLength: 1 + maxLength: 100 + email: + type: string + format: email + profile: + $ref: "#/components/schemas/UserProfile" + responses: + UserResponse: + description: Single user response + content: + application/json: + schema: + $ref: "#/components/schemas/User" + examples: + user_example: + $ref: "#/components/examples/UserExample" + UserListResponse: + description: List of users response + content: + application/json: + schema: + type: object + properties: + users: + type: array + items: + $ref: "#/components/schemas/User" + total: + type: integer + minimum: 0 + limit: + type: integer + minimum: 1 + offset: + type: integer + minimum: 0 + ErrorResponse: + description: Error response + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + examples: + UserExample: + summary: Example user + description: An example user object + value: + id: "123e4567-e89b-12d3-a456-426614174000" + name: "John Doe" + email: "john.doe@example.com" + profile: + bio: "Software developer" + avatar_url: "https://example.com/avatar.jpg" + social_links: + github: "https://github.com/johndoe" + twitter: "https://twitter.com/johndoe" + preferences: + theme: "dark" + notifications: + email: true + push: false + PostsExample: + summary: Example posts + description: An example list of posts + value: + - id: "456e7890-e89b-12d3-a456-426614174001" + title: "My First Post" + content: "This is my first post content" + author_id: "123e4567-e89b-12d3-a456-426614174000" + tags: ["introduction", "first-post"] + created_at: "2023-01-01T12:00:00Z" + updated_at: "2023-01-01T12:00:00Z" + headers: + X-Rate-Limit: + description: Rate limit information + schema: + type: integer + minimum: 0 + securitySchemes: + BearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: JWT Bearer token authentication +security: + - BearerAuth: [] diff --git a/openapi/testdata/inline/external_api.yaml b/openapi/testdata/inline/external_api.yaml new file mode 100644 index 0000000..cc7bd46 --- /dev/null +++ b/openapi/testdata/inline/external_api.yaml @@ -0,0 +1,129 @@ +openapi: 3.1.0 +info: + title: External API + version: 1.0.0 + description: External API with User components + +components: + schemas: + User: + type: object + required: + - id + - name + - email + properties: + id: + type: string + format: uuid + description: Unique user identifier + name: + type: string + minLength: 1 + maxLength: 100 + description: User's full name + email: + type: string + format: email + description: User's email address + profile: + $ref: "#/components/schemas/UserProfile" + posts: + type: array + items: + $ref: "#/components/schemas/Post" + description: Posts created by this user + + UserProfile: + type: object + properties: + bio: + type: string + maxLength: 500 + description: User biography + avatar_url: + type: string + format: uri + description: URL to user's avatar image + social_links: + type: object + additionalProperties: + type: string + format: uri + preferences: + $ref: "#/components/schemas/UserPreferences" + + UserPreferences: + type: object + properties: + theme: + type: string + enum: [light, dark, auto] + default: auto + notifications: + type: object + properties: + email: + type: boolean + default: true + push: + type: boolean + default: false + + Post: + type: object + required: + - id + - title + - content + - author_id + properties: + id: + type: string + format: uuid + title: + type: string + minLength: 1 + maxLength: 200 + content: + type: string + minLength: 1 + author_id: + type: string + format: uuid + author: + $ref: "#/components/schemas/User" + tags: + type: array + items: + type: string + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + # A different schema for variety + Organization: + type: object + required: + - id + - name + properties: + id: + type: string + format: uuid + name: + type: string + minLength: 1 + maxLength: 200 + description: + type: string + website: + type: string + format: uri + members: + type: array + items: + $ref: "#/components/schemas/User" diff --git a/openapi/testdata/inline/external_conflicting_user.yaml b/openapi/testdata/inline/external_conflicting_user.yaml new file mode 100644 index 0000000..2823145 --- /dev/null +++ b/openapi/testdata/inline/external_conflicting_user.yaml @@ -0,0 +1,34 @@ +# Conflicting User schema - different structure to test conflict detection +User: + type: object + required: + - userId + - username + properties: + userId: + type: integer + description: Numeric user identifier (conflicts with string id) + username: + type: string + minLength: 3 + maxLength: 50 + description: User's username (conflicts with name field) + email: + type: string + format: email + description: User's email address + role: + type: string + enum: + - admin + - user + - guest + default: user + description: User's role in the system + createdAt: + type: string + format: date-time + description: When the user was created + manager: + $ref: "#/User" + description: User's manager (creates circular reference) diff --git a/openapi/testdata/inline/external_post.yaml b/openapi/testdata/inline/external_post.yaml new file mode 100644 index 0000000..6deec80 --- /dev/null +++ b/openapi/testdata/inline/external_post.yaml @@ -0,0 +1,33 @@ +# External Post schema - creates circular reference with external User +type: object +required: + - id + - title + - content + - author_id +properties: + id: + type: string + format: uuid + title: + type: string + minLength: 1 + maxLength: 200 + content: + type: string + minLength: 1 + author_id: + type: string + format: uuid + author: + $ref: "external_user.yaml" + tags: + type: array + items: + type: string + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time diff --git a/openapi/testdata/inline/external_simple_schema.yaml b/openapi/testdata/inline/external_simple_schema.yaml new file mode 100644 index 0000000..549d2fa --- /dev/null +++ b/openapi/testdata/inline/external_simple_schema.yaml @@ -0,0 +1,19 @@ +# Simple external schema without circular references +type: object +required: + - id + - name +properties: + id: + type: string + format: uuid + name: + type: string + minLength: 1 + maxLength: 100 + description: + type: string + maxLength: 500 + created_at: + type: string + format: date-time diff --git a/openapi/testdata/inline/external_user.yaml b/openapi/testdata/inline/external_user.yaml new file mode 100644 index 0000000..91e70d2 --- /dev/null +++ b/openapi/testdata/inline/external_user.yaml @@ -0,0 +1,27 @@ +# External User schema - same as component but external +type: object +required: + - id + - name + - email +properties: + id: + type: string + format: uuid + description: Unique user identifier + name: + type: string + minLength: 1 + maxLength: 100 + description: User's full name + email: + type: string + format: email + description: User's email address + profile: + $ref: "external_user_profile.yaml" + posts: + type: array + items: + $ref: "external_post.yaml" + description: Posts created by this user diff --git a/openapi/testdata/inline/external_user_preferences.yaml b/openapi/testdata/inline/external_user_preferences.yaml new file mode 100644 index 0000000..636f24c --- /dev/null +++ b/openapi/testdata/inline/external_user_preferences.yaml @@ -0,0 +1,16 @@ +# External UserPreferences schema +type: object +properties: + theme: + type: string + enum: [light, dark, auto] + default: auto + notifications: + type: object + properties: + email: + type: boolean + default: true + push: + type: boolean + default: false diff --git a/openapi/testdata/inline/external_user_profile.yaml b/openapi/testdata/inline/external_user_profile.yaml new file mode 100644 index 0000000..189a659 --- /dev/null +++ b/openapi/testdata/inline/external_user_profile.yaml @@ -0,0 +1,18 @@ +# External UserProfile schema +type: object +properties: + bio: + type: string + maxLength: 500 + description: User biography + avatar_url: + type: string + format: uri + description: URL to user's avatar image + social_links: + type: object + additionalProperties: + type: string + format: uri + preferences: + $ref: "external_user_preferences.yaml" diff --git a/openapi/testdata/inline/inline_current.yaml b/openapi/testdata/inline/inline_current.yaml new file mode 100644 index 0000000..83c1c2c --- /dev/null +++ b/openapi/testdata/inline/inline_current.yaml @@ -0,0 +1,914 @@ +openapi: 3.1.0 +info: + title: Enhanced Test API with Complex References + version: 1.0.0 + description: A comprehensive test document with circular references, external refs, and duplicate schemas + contact: + name: Test Contact + url: https://example.com + license: + name: MIT + url: https://opensource.org/licenses/MIT +servers: + - url: https://api.example.com/v1 + description: Production server +tags: + - name: users + description: User operations + - name: posts + description: Post operations + - name: organizations + description: Organization operations + - name: external + description: External reference operations +paths: + /users: + get: + tags: + - users + summary: List users + parameters: + - name: limit + in: query + description: Maximum number of items to return + schema: + type: integer + maximum: 100 + minimum: 1 + default: 20 + - name: offset + in: query + description: Number of items to skip + schema: + type: integer + minimum: 0 + default: 0 + responses: + "200": + description: List of users response + content: + application/json: + schema: + type: object + properties: + users: + type: array + items: + $ref: '#/components/schemas/User' + total: + type: integer + minimum: 0 + limit: + type: integer + minimum: 1 + offset: + type: integer + minimum: 0 + "400": + description: Error response + content: + application/json: + schema: + type: object + properties: + code: + type: string + description: Error code + message: + type: string + description: Error message + details: + type: object + additionalProperties: true + description: Additional error details + required: + - code + - message + post: + tags: + - users + summary: Create user + requestBody: + description: Request body for creating a user + content: + application/json: + schema: + type: object + properties: + name: + type: string + maxLength: 100 + minLength: 1 + email: + type: string + format: email + profile: + type: object + properties: + bio: + type: string + maxLength: 500 + description: User biography + avatar_url: + type: string + format: uri + description: URL to user's avatar image + social_links: + type: object + additionalProperties: + type: string + format: uri + preferences: + type: object + properties: + theme: + type: string + enum: + - light + - dark + - auto + default: auto + notifications: + type: object + properties: + email: + type: boolean + default: true + push: + type: boolean + default: false + required: + - name + - email + required: true + responses: + "201": + description: Single user response + content: + application/json: + schema: + $ref: '#/components/schemas/User' + examples: + user_example: + summary: Example user + description: An example user object + value: + id: "123e4567-e89b-12d3-a456-426614174000" + name: "John Doe" + email: "john.doe@example.com" + profile: + bio: "Software developer" + avatar_url: "https://example.com/avatar.jpg" + social_links: + github: "https://github.com/johndoe" + twitter: "https://twitter.com/johndoe" + preferences: + theme: "dark" + notifications: + email: true + push: false + "400": + description: Error response + content: + application/json: + schema: + type: object + properties: + code: + type: string + description: Error code + message: + type: string + description: Error message + details: + type: object + additionalProperties: true + description: Additional error details + required: + - code + - message + /users/{id}: + parameters: + - name: id + in: path + description: User ID + required: true + schema: + type: string + format: uuid + get: + tags: + - users + summary: Get user by ID + responses: + "200": + description: Single user response + content: + application/json: + schema: + $ref: '#/components/schemas/User' + examples: + user_example: + summary: Example user + description: An example user object + value: + id: "123e4567-e89b-12d3-a456-426614174000" + name: "John Doe" + email: "john.doe@example.com" + profile: + bio: "Software developer" + avatar_url: "https://example.com/avatar.jpg" + social_links: + github: "https://github.com/johndoe" + twitter: "https://twitter.com/johndoe" + preferences: + theme: "dark" + notifications: + email: true + push: false + "404": + description: Error response + content: + application/json: + schema: + type: object + properties: + code: + type: string + description: Error code + message: + type: string + description: Error message + details: + type: object + additionalProperties: true + description: Additional error details + required: + - code + - message + put: + tags: + - users + summary: Update user + requestBody: + description: Request body for updating a user + content: + application/json: + schema: + type: object + properties: + name: + type: string + maxLength: 100 + minLength: 1 + email: + type: string + format: email + profile: + type: object + properties: + bio: + type: string + maxLength: 500 + description: User biography + avatar_url: + type: string + format: uri + description: URL to user's avatar image + social_links: + type: object + additionalProperties: + type: string + format: uri + preferences: + type: object + properties: + theme: + type: string + enum: + - light + - dark + - auto + default: auto + notifications: + type: object + properties: + email: + type: boolean + default: true + push: + type: boolean + default: false + required: true + responses: + "200": + description: Single user response + content: + application/json: + schema: + $ref: '#/components/schemas/User' + examples: + user_example: + summary: Example user + description: An example user object + value: + id: "123e4567-e89b-12d3-a456-426614174000" + name: "John Doe" + email: "john.doe@example.com" + profile: + bio: "Software developer" + avatar_url: "https://example.com/avatar.jpg" + social_links: + github: "https://github.com/johndoe" + twitter: "https://twitter.com/johndoe" + preferences: + theme: "dark" + notifications: + email: true + push: false + "404": + description: Error response + content: + application/json: + schema: + type: object + properties: + code: + type: string + description: Error code + message: + type: string + description: Error message + details: + type: object + additionalProperties: true + description: Additional error details + required: + - code + - message + /external-users: + get: + tags: + - users + summary: Get external users (uses conflicting User schema) + responses: + "200": + description: List of external users + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/User_1" + /posts: + get: + tags: + - posts + summary: List posts + responses: + "200": + description: List of posts + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Post" + examples: + posts_example: + summary: Example posts + description: An example list of posts + value: + - id: "456e7890-e89b-12d3-a456-426614174001" + title: "My First Post" + content: "This is my first post content" + author_id: "123e4567-e89b-12d3-a456-426614174000" + tags: ["introduction", "first-post"] + created_at: "2023-01-01T12:00:00Z" + updated_at: "2023-01-01T12:00:00Z" + post: + tags: + - posts + summary: Create post (uses circular Post schema) + requestBody: + description: Post creation data + required: true + content: + application/json: + schema: + type: object + required: [title, content, author_id] + properties: + title: + type: string + minLength: 1 + maxLength: 200 + content: + type: string + minLength: 1 + author_id: + type: string + format: uuid + tags: + type: array + items: + type: string + responses: + "201": + description: Created post + content: + application/json: + schema: + $ref: "#/components/schemas/Post" + # Additional operations using the same circular reference schemas + /users/{id}/posts: + parameters: + - name: id + in: path + description: User ID + required: true + schema: + type: string + format: uuid + get: + tags: [posts] + summary: Get user posts (uses circular Post schema) + responses: + "200": + description: List of user posts + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Post" + "404": + description: Error response + content: + application/json: + schema: + type: object + properties: + code: + type: string + description: Error code + message: + type: string + description: Error message + details: + type: object + additionalProperties: true + description: Additional error details + required: + - code + - message + /posts/{id}: + parameters: + - name: id + in: path + required: true + description: Post ID + schema: + type: string + format: uuid + get: + tags: [posts] + summary: Get post by ID (uses circular Post schema) + responses: + "200": + description: Single post + content: + application/json: + schema: + $ref: "#/components/schemas/Post" + "404": + description: Error response + content: + application/json: + schema: + type: object + properties: + code: + type: string + description: Error code + message: + type: string + description: Error message + details: + type: object + additionalProperties: true + description: Additional error details + required: + - code + - message + # External reference operations + /external/users: + get: + tags: [external] + summary: Get external users (external ref to same User schema) + responses: + "200": + description: External users + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/User" + /external/organizations: + get: + tags: [external] + summary: Get organizations (external ref to different schema) + responses: + "200": + description: Organizations + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + maxLength: 200 + minLength: 1 + description: + type: string + website: + type: string + format: uri + members: + type: array + items: + $ref: '#/components/schemas/User' + required: + - id + - name + /external/simple: + get: + tags: [external] + summary: Get simple external data + responses: + "200": + description: Simple external data + content: + application/json: + schema: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + maxLength: 100 + minLength: 1 + description: + type: string + maxLength: 500 + created_at: + type: string + format: date-time + required: + - id + - name + /mixed/user-with-external-profile: + get: + tags: [external] + summary: Mixed internal/external references + responses: + "200": + description: User with external profile + content: + application/json: + schema: + type: object + properties: + user: + $ref: "#/components/schemas/User" + external_profile: + type: object + properties: + bio: + type: string + maxLength: 500 + description: User biography + avatar_url: + type: string + format: uri + description: URL to user's avatar image + social_links: + type: object + additionalProperties: + type: string + format: uri + preferences: + type: object + properties: + theme: + type: string + enum: + - light + - dark + - auto + default: auto + notifications: + type: object + properties: + email: + type: boolean + default: true + push: + type: boolean + default: false + simple_data: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + maxLength: 100 + minLength: 1 + description: + type: string + maxLength: 500 + created_at: + type: string + format: date-time + required: + - id + - name + # More operations to test $defs duplication + /users/search: + get: + tags: [users] + summary: Search users (another operation using User schema) + parameters: + - name: q + in: query + description: Search query + schema: + type: string + responses: + "200": + description: Search results + content: + application/json: + schema: + type: object + properties: + results: + type: array + items: + $ref: "#/components/schemas/User" + total: + type: integer + /posts/search: + get: + tags: [posts] + summary: Search posts (another operation using Post schema) + parameters: + - name: q + in: query + description: Search query + schema: + type: string + responses: + "200": + description: Search results + content: + application/json: + schema: + type: object + properties: + results: + type: array + items: + $ref: "#/components/schemas/Post" + total: + type: integer +components: + schemas: + User: + type: object + required: + - id + - name + - email + properties: + id: + type: string + format: uuid + description: Unique user identifier + name: + type: string + minLength: 1 + maxLength: 100 + description: User's full name + email: + type: string + format: email + description: User's email address + profile: + type: object + properties: + bio: + type: string + maxLength: 500 + description: User biography + avatar_url: + type: string + format: uri + description: URL to user's avatar image + social_links: + type: object + additionalProperties: + type: string + format: uri + preferences: + type: object + properties: + theme: + type: string + enum: + - light + - dark + - auto + default: auto + notifications: + type: object + properties: + email: + type: boolean + default: true + push: + type: boolean + default: false + posts: + type: array + items: + type: object + properties: + id: + type: string + format: uuid + title: + type: string + maxLength: 200 + minLength: 1 + content: + type: string + minLength: 1 + author_id: + type: string + format: uuid + author: + $ref: '#/components/schemas/User' + tags: + type: array + items: + type: string + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + required: + - id + - title + - content + - author_id + description: Posts created by this user + Post: + type: object + required: + - id + - title + - content + - author_id + properties: + id: + type: string + format: uuid + title: + type: string + minLength: 1 + maxLength: 200 + content: + type: string + minLength: 1 + author_id: + type: string + format: uuid + author: + type: object + properties: + id: + type: string + format: uuid + description: Unique user identifier + name: + type: string + maxLength: 100 + minLength: 1 + description: User's full name + email: + type: string + format: email + description: User's email address + profile: + type: object + properties: + bio: + type: string + maxLength: 500 + description: User biography + avatar_url: + type: string + format: uri + description: URL to user's avatar image + social_links: + type: object + additionalProperties: + type: string + format: uri + preferences: + type: object + properties: + theme: + type: string + enum: + - light + - dark + - auto + default: auto + notifications: + type: object + properties: + email: + type: boolean + default: true + push: + type: boolean + default: false + posts: + type: array + items: + $ref: '#/components/schemas/Post' + description: Posts created by this user + required: + - id + - name + - email + tags: + type: array + items: + type: string + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + User_1: + type: object + properties: + userId: + type: integer + description: Numeric user identifier (conflicts with string id) + username: + type: string + maxLength: 50 + minLength: 3 + description: User's username (conflicts with name field) + email: + type: string + format: email + description: User's email address + role: + type: string + enum: + - admin + - user + - guest + description: User's role in the system + default: user + createdAt: + type: string + format: date-time + description: When the user was created + manager: + $ref: '#/components/schemas/User_1' + description: User's manager (creates circular reference) + required: + - userId + - username +security: + - BearerAuth: [] diff --git a/openapi/testdata/inline/inline_expected.yaml b/openapi/testdata/inline/inline_expected.yaml new file mode 100644 index 0000000..83c1c2c --- /dev/null +++ b/openapi/testdata/inline/inline_expected.yaml @@ -0,0 +1,914 @@ +openapi: 3.1.0 +info: + title: Enhanced Test API with Complex References + version: 1.0.0 + description: A comprehensive test document with circular references, external refs, and duplicate schemas + contact: + name: Test Contact + url: https://example.com + license: + name: MIT + url: https://opensource.org/licenses/MIT +servers: + - url: https://api.example.com/v1 + description: Production server +tags: + - name: users + description: User operations + - name: posts + description: Post operations + - name: organizations + description: Organization operations + - name: external + description: External reference operations +paths: + /users: + get: + tags: + - users + summary: List users + parameters: + - name: limit + in: query + description: Maximum number of items to return + schema: + type: integer + maximum: 100 + minimum: 1 + default: 20 + - name: offset + in: query + description: Number of items to skip + schema: + type: integer + minimum: 0 + default: 0 + responses: + "200": + description: List of users response + content: + application/json: + schema: + type: object + properties: + users: + type: array + items: + $ref: '#/components/schemas/User' + total: + type: integer + minimum: 0 + limit: + type: integer + minimum: 1 + offset: + type: integer + minimum: 0 + "400": + description: Error response + content: + application/json: + schema: + type: object + properties: + code: + type: string + description: Error code + message: + type: string + description: Error message + details: + type: object + additionalProperties: true + description: Additional error details + required: + - code + - message + post: + tags: + - users + summary: Create user + requestBody: + description: Request body for creating a user + content: + application/json: + schema: + type: object + properties: + name: + type: string + maxLength: 100 + minLength: 1 + email: + type: string + format: email + profile: + type: object + properties: + bio: + type: string + maxLength: 500 + description: User biography + avatar_url: + type: string + format: uri + description: URL to user's avatar image + social_links: + type: object + additionalProperties: + type: string + format: uri + preferences: + type: object + properties: + theme: + type: string + enum: + - light + - dark + - auto + default: auto + notifications: + type: object + properties: + email: + type: boolean + default: true + push: + type: boolean + default: false + required: + - name + - email + required: true + responses: + "201": + description: Single user response + content: + application/json: + schema: + $ref: '#/components/schemas/User' + examples: + user_example: + summary: Example user + description: An example user object + value: + id: "123e4567-e89b-12d3-a456-426614174000" + name: "John Doe" + email: "john.doe@example.com" + profile: + bio: "Software developer" + avatar_url: "https://example.com/avatar.jpg" + social_links: + github: "https://github.com/johndoe" + twitter: "https://twitter.com/johndoe" + preferences: + theme: "dark" + notifications: + email: true + push: false + "400": + description: Error response + content: + application/json: + schema: + type: object + properties: + code: + type: string + description: Error code + message: + type: string + description: Error message + details: + type: object + additionalProperties: true + description: Additional error details + required: + - code + - message + /users/{id}: + parameters: + - name: id + in: path + description: User ID + required: true + schema: + type: string + format: uuid + get: + tags: + - users + summary: Get user by ID + responses: + "200": + description: Single user response + content: + application/json: + schema: + $ref: '#/components/schemas/User' + examples: + user_example: + summary: Example user + description: An example user object + value: + id: "123e4567-e89b-12d3-a456-426614174000" + name: "John Doe" + email: "john.doe@example.com" + profile: + bio: "Software developer" + avatar_url: "https://example.com/avatar.jpg" + social_links: + github: "https://github.com/johndoe" + twitter: "https://twitter.com/johndoe" + preferences: + theme: "dark" + notifications: + email: true + push: false + "404": + description: Error response + content: + application/json: + schema: + type: object + properties: + code: + type: string + description: Error code + message: + type: string + description: Error message + details: + type: object + additionalProperties: true + description: Additional error details + required: + - code + - message + put: + tags: + - users + summary: Update user + requestBody: + description: Request body for updating a user + content: + application/json: + schema: + type: object + properties: + name: + type: string + maxLength: 100 + minLength: 1 + email: + type: string + format: email + profile: + type: object + properties: + bio: + type: string + maxLength: 500 + description: User biography + avatar_url: + type: string + format: uri + description: URL to user's avatar image + social_links: + type: object + additionalProperties: + type: string + format: uri + preferences: + type: object + properties: + theme: + type: string + enum: + - light + - dark + - auto + default: auto + notifications: + type: object + properties: + email: + type: boolean + default: true + push: + type: boolean + default: false + required: true + responses: + "200": + description: Single user response + content: + application/json: + schema: + $ref: '#/components/schemas/User' + examples: + user_example: + summary: Example user + description: An example user object + value: + id: "123e4567-e89b-12d3-a456-426614174000" + name: "John Doe" + email: "john.doe@example.com" + profile: + bio: "Software developer" + avatar_url: "https://example.com/avatar.jpg" + social_links: + github: "https://github.com/johndoe" + twitter: "https://twitter.com/johndoe" + preferences: + theme: "dark" + notifications: + email: true + push: false + "404": + description: Error response + content: + application/json: + schema: + type: object + properties: + code: + type: string + description: Error code + message: + type: string + description: Error message + details: + type: object + additionalProperties: true + description: Additional error details + required: + - code + - message + /external-users: + get: + tags: + - users + summary: Get external users (uses conflicting User schema) + responses: + "200": + description: List of external users + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/User_1" + /posts: + get: + tags: + - posts + summary: List posts + responses: + "200": + description: List of posts + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Post" + examples: + posts_example: + summary: Example posts + description: An example list of posts + value: + - id: "456e7890-e89b-12d3-a456-426614174001" + title: "My First Post" + content: "This is my first post content" + author_id: "123e4567-e89b-12d3-a456-426614174000" + tags: ["introduction", "first-post"] + created_at: "2023-01-01T12:00:00Z" + updated_at: "2023-01-01T12:00:00Z" + post: + tags: + - posts + summary: Create post (uses circular Post schema) + requestBody: + description: Post creation data + required: true + content: + application/json: + schema: + type: object + required: [title, content, author_id] + properties: + title: + type: string + minLength: 1 + maxLength: 200 + content: + type: string + minLength: 1 + author_id: + type: string + format: uuid + tags: + type: array + items: + type: string + responses: + "201": + description: Created post + content: + application/json: + schema: + $ref: "#/components/schemas/Post" + # Additional operations using the same circular reference schemas + /users/{id}/posts: + parameters: + - name: id + in: path + description: User ID + required: true + schema: + type: string + format: uuid + get: + tags: [posts] + summary: Get user posts (uses circular Post schema) + responses: + "200": + description: List of user posts + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Post" + "404": + description: Error response + content: + application/json: + schema: + type: object + properties: + code: + type: string + description: Error code + message: + type: string + description: Error message + details: + type: object + additionalProperties: true + description: Additional error details + required: + - code + - message + /posts/{id}: + parameters: + - name: id + in: path + required: true + description: Post ID + schema: + type: string + format: uuid + get: + tags: [posts] + summary: Get post by ID (uses circular Post schema) + responses: + "200": + description: Single post + content: + application/json: + schema: + $ref: "#/components/schemas/Post" + "404": + description: Error response + content: + application/json: + schema: + type: object + properties: + code: + type: string + description: Error code + message: + type: string + description: Error message + details: + type: object + additionalProperties: true + description: Additional error details + required: + - code + - message + # External reference operations + /external/users: + get: + tags: [external] + summary: Get external users (external ref to same User schema) + responses: + "200": + description: External users + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/User" + /external/organizations: + get: + tags: [external] + summary: Get organizations (external ref to different schema) + responses: + "200": + description: Organizations + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + maxLength: 200 + minLength: 1 + description: + type: string + website: + type: string + format: uri + members: + type: array + items: + $ref: '#/components/schemas/User' + required: + - id + - name + /external/simple: + get: + tags: [external] + summary: Get simple external data + responses: + "200": + description: Simple external data + content: + application/json: + schema: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + maxLength: 100 + minLength: 1 + description: + type: string + maxLength: 500 + created_at: + type: string + format: date-time + required: + - id + - name + /mixed/user-with-external-profile: + get: + tags: [external] + summary: Mixed internal/external references + responses: + "200": + description: User with external profile + content: + application/json: + schema: + type: object + properties: + user: + $ref: "#/components/schemas/User" + external_profile: + type: object + properties: + bio: + type: string + maxLength: 500 + description: User biography + avatar_url: + type: string + format: uri + description: URL to user's avatar image + social_links: + type: object + additionalProperties: + type: string + format: uri + preferences: + type: object + properties: + theme: + type: string + enum: + - light + - dark + - auto + default: auto + notifications: + type: object + properties: + email: + type: boolean + default: true + push: + type: boolean + default: false + simple_data: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + maxLength: 100 + minLength: 1 + description: + type: string + maxLength: 500 + created_at: + type: string + format: date-time + required: + - id + - name + # More operations to test $defs duplication + /users/search: + get: + tags: [users] + summary: Search users (another operation using User schema) + parameters: + - name: q + in: query + description: Search query + schema: + type: string + responses: + "200": + description: Search results + content: + application/json: + schema: + type: object + properties: + results: + type: array + items: + $ref: "#/components/schemas/User" + total: + type: integer + /posts/search: + get: + tags: [posts] + summary: Search posts (another operation using Post schema) + parameters: + - name: q + in: query + description: Search query + schema: + type: string + responses: + "200": + description: Search results + content: + application/json: + schema: + type: object + properties: + results: + type: array + items: + $ref: "#/components/schemas/Post" + total: + type: integer +components: + schemas: + User: + type: object + required: + - id + - name + - email + properties: + id: + type: string + format: uuid + description: Unique user identifier + name: + type: string + minLength: 1 + maxLength: 100 + description: User's full name + email: + type: string + format: email + description: User's email address + profile: + type: object + properties: + bio: + type: string + maxLength: 500 + description: User biography + avatar_url: + type: string + format: uri + description: URL to user's avatar image + social_links: + type: object + additionalProperties: + type: string + format: uri + preferences: + type: object + properties: + theme: + type: string + enum: + - light + - dark + - auto + default: auto + notifications: + type: object + properties: + email: + type: boolean + default: true + push: + type: boolean + default: false + posts: + type: array + items: + type: object + properties: + id: + type: string + format: uuid + title: + type: string + maxLength: 200 + minLength: 1 + content: + type: string + minLength: 1 + author_id: + type: string + format: uuid + author: + $ref: '#/components/schemas/User' + tags: + type: array + items: + type: string + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + required: + - id + - title + - content + - author_id + description: Posts created by this user + Post: + type: object + required: + - id + - title + - content + - author_id + properties: + id: + type: string + format: uuid + title: + type: string + minLength: 1 + maxLength: 200 + content: + type: string + minLength: 1 + author_id: + type: string + format: uuid + author: + type: object + properties: + id: + type: string + format: uuid + description: Unique user identifier + name: + type: string + maxLength: 100 + minLength: 1 + description: User's full name + email: + type: string + format: email + description: User's email address + profile: + type: object + properties: + bio: + type: string + maxLength: 500 + description: User biography + avatar_url: + type: string + format: uri + description: URL to user's avatar image + social_links: + type: object + additionalProperties: + type: string + format: uri + preferences: + type: object + properties: + theme: + type: string + enum: + - light + - dark + - auto + default: auto + notifications: + type: object + properties: + email: + type: boolean + default: true + push: + type: boolean + default: false + posts: + type: array + items: + $ref: '#/components/schemas/Post' + description: Posts created by this user + required: + - id + - name + - email + tags: + type: array + items: + type: string + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + User_1: + type: object + properties: + userId: + type: integer + description: Numeric user identifier (conflicts with string id) + username: + type: string + maxLength: 50 + minLength: 3 + description: User's username (conflicts with name field) + email: + type: string + format: email + description: User's email address + role: + type: string + enum: + - admin + - user + - guest + description: User's role in the system + default: user + createdAt: + type: string + format: date-time + description: When the user was created + manager: + $ref: '#/components/schemas/User_1' + description: User's manager (creates circular reference) + required: + - userId + - username +security: + - BearerAuth: [] diff --git a/openapi/testdata/inline/inline_input.yaml b/openapi/testdata/inline/inline_input.yaml new file mode 100644 index 0000000..60bf1d8 --- /dev/null +++ b/openapi/testdata/inline/inline_input.yaml @@ -0,0 +1,583 @@ +openapi: 3.1.0 +info: + title: Enhanced Test API with Complex References + version: 1.0.0 + description: A comprehensive test document with circular references, external refs, and duplicate schemas + contact: + name: Test Contact + url: https://example.com + license: + name: MIT + url: https://opensource.org/licenses/MIT + +servers: + - url: https://api.example.com/v1 + description: Production server + +tags: + - name: users + description: User operations + - name: posts + description: Post operations + - name: organizations + description: Organization operations + - name: external + description: External reference operations + +paths: + /users: + get: + tags: + - users + summary: List users + parameters: + - $ref: "#/components/parameters/LimitParam" + - $ref: "#/components/parameters/OffsetParam" + responses: + "200": + $ref: "#/components/responses/UserListResponse" + "400": + $ref: "#/components/responses/ErrorResponse" + post: + tags: + - users + summary: Create user + requestBody: + $ref: "#/components/requestBodies/CreateUserRequest" + responses: + "201": + $ref: "#/components/responses/UserResponse" + "400": + $ref: "#/components/responses/ErrorResponse" + + /users/{id}: + parameters: + - $ref: "#/components/parameters/UserIdParam" + get: + tags: + - users + summary: Get user by ID + responses: + "200": + $ref: "#/components/responses/UserResponse" + "404": + $ref: "#/components/responses/ErrorResponse" + put: + tags: + - users + summary: Update user + requestBody: + $ref: "#/components/requestBodies/UpdateUserRequest" + responses: + "200": + $ref: "#/components/responses/UserResponse" + "404": + $ref: "#/components/responses/ErrorResponse" + + /external-users: + get: + tags: + - users + summary: Get external users (uses conflicting User schema) + responses: + "200": + description: List of external users + content: + application/json: + schema: + type: array + items: + $ref: "external_conflicting_user.yaml#/User" + + /posts: + get: + tags: + - posts + summary: List posts + responses: + "200": + description: List of posts + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Post" + examples: + posts_example: + $ref: "#/components/examples/PostsExample" + post: + tags: + - posts + summary: Create post (uses circular Post schema) + requestBody: + description: Post creation data + required: true + content: + application/json: + schema: + type: object + required: [title, content, author_id] + properties: + title: + type: string + minLength: 1 + maxLength: 200 + content: + type: string + minLength: 1 + author_id: + type: string + format: uuid + tags: + type: array + items: + type: string + responses: + "201": + description: Created post + content: + application/json: + schema: + $ref: "#/components/schemas/Post" + + # Additional operations using the same circular reference schemas + /users/{id}/posts: + parameters: + - $ref: "#/components/parameters/UserIdParam" + get: + tags: [posts] + summary: Get user posts (uses circular Post schema) + responses: + "200": + description: List of user posts + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Post" + "404": + $ref: "#/components/responses/ErrorResponse" + + /posts/{id}: + parameters: + - name: id + in: path + required: true + description: Post ID + schema: + type: string + format: uuid + get: + tags: [posts] + summary: Get post by ID (uses circular Post schema) + responses: + "200": + description: Single post + content: + application/json: + schema: + $ref: "#/components/schemas/Post" + "404": + $ref: "#/components/responses/ErrorResponse" + + # External reference operations + /external/users: + get: + tags: [external] + summary: Get external users (external ref to same User schema) + responses: + "200": + description: External users + content: + application/json: + schema: + type: array + items: + $ref: "external_api.yaml#/components/schemas/User" + + /external/organizations: + get: + tags: [external] + summary: Get organizations (external ref to different schema) + responses: + "200": + description: Organizations + content: + application/json: + schema: + type: array + items: + $ref: "external_api.yaml#/components/schemas/Organization" + + /external/simple: + get: + tags: [external] + summary: Get simple external data + responses: + "200": + description: Simple external data + content: + application/json: + schema: + $ref: "external_simple_schema.yaml" + + /mixed/user-with-external-profile: + get: + tags: [external] + summary: Mixed internal/external references + responses: + "200": + description: User with external profile + content: + application/json: + schema: + type: object + properties: + user: + $ref: "#/components/schemas/User" + external_profile: + $ref: "external_user_profile.yaml" + simple_data: + $ref: "external_simple_schema.yaml" + + # More operations to test $defs duplication + /users/search: + get: + tags: [users] + summary: Search users (another operation using User schema) + parameters: + - name: q + in: query + description: Search query + schema: + type: string + responses: + "200": + description: Search results + content: + application/json: + schema: + type: object + properties: + results: + type: array + items: + $ref: "#/components/schemas/User" + total: + type: integer + + /posts/search: + get: + tags: [posts] + summary: Search posts (another operation using Post schema) + parameters: + - name: q + in: query + description: Search query + schema: + type: string + responses: + "200": + description: Search results + content: + application/json: + schema: + type: object + properties: + results: + type: array + items: + $ref: "#/components/schemas/Post" + total: + type: integer + +components: + parameters: + LimitParam: + name: limit + in: query + description: Maximum number of items to return + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 + + OffsetParam: + name: offset + in: query + description: Number of items to skip + schema: + type: integer + minimum: 0 + default: 0 + + UserIdParam: + name: id + in: path + required: true + description: User ID + schema: + type: string + format: uuid + + schemas: + User: + type: object + required: + - id + - name + - email + properties: + id: + type: string + format: uuid + description: Unique user identifier + name: + type: string + minLength: 1 + maxLength: 100 + description: User's full name + email: + type: string + format: email + description: User's email address + profile: + $ref: "#/components/schemas/UserProfile" + posts: + type: array + items: + $ref: "#/components/schemas/Post" + description: Posts created by this user + + UserProfile: + type: object + properties: + bio: + type: string + maxLength: 500 + description: User biography + avatar_url: + type: string + format: uri + description: URL to user's avatar image + social_links: + type: object + additionalProperties: + type: string + format: uri + preferences: + $ref: "#/components/schemas/UserPreferences" + + UserPreferences: + type: object + properties: + theme: + type: string + enum: [light, dark, auto] + default: auto + notifications: + type: object + properties: + email: + type: boolean + default: true + push: + type: boolean + default: false + + Post: + type: object + required: + - id + - title + - content + - author_id + properties: + id: + type: string + format: uuid + title: + type: string + minLength: 1 + maxLength: 200 + content: + type: string + minLength: 1 + author_id: + type: string + format: uuid + author: + $ref: "#/components/schemas/User" + tags: + type: array + items: + type: string + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + # Circular reference example - Tree structure + TreeNode: + type: object + properties: + id: + type: string + name: + type: string + children: + type: array + items: + $ref: "#/components/schemas/TreeNode" + parent: + $ref: "#/components/schemas/TreeNode" + + Error: + type: object + required: + - code + - message + properties: + code: + type: string + description: Error code + message: + type: string + description: Error message + details: + type: object + additionalProperties: true + description: Additional error details + + requestBodies: + CreateUserRequest: + description: Request body for creating a user + required: true + content: + application/json: + schema: + type: object + required: + - name + - email + properties: + name: + type: string + minLength: 1 + maxLength: 100 + email: + type: string + format: email + profile: + $ref: "#/components/schemas/UserProfile" + + UpdateUserRequest: + description: Request body for updating a user + required: true + content: + application/json: + schema: + type: object + properties: + name: + type: string + minLength: 1 + maxLength: 100 + email: + type: string + format: email + profile: + $ref: "#/components/schemas/UserProfile" + + responses: + UserResponse: + description: Single user response + content: + application/json: + schema: + $ref: "#/components/schemas/User" + examples: + user_example: + $ref: "#/components/examples/UserExample" + + UserListResponse: + description: List of users response + content: + application/json: + schema: + type: object + properties: + users: + type: array + items: + $ref: "#/components/schemas/User" + total: + type: integer + minimum: 0 + limit: + type: integer + minimum: 1 + offset: + type: integer + minimum: 0 + + ErrorResponse: + description: Error response + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + examples: + UserExample: + summary: Example user + description: An example user object + value: + id: "123e4567-e89b-12d3-a456-426614174000" + name: "John Doe" + email: "john.doe@example.com" + profile: + bio: "Software developer" + avatar_url: "https://example.com/avatar.jpg" + social_links: + github: "https://github.com/johndoe" + twitter: "https://twitter.com/johndoe" + preferences: + theme: "dark" + notifications: + email: true + push: false + + PostsExample: + summary: Example posts + description: An example list of posts + value: + - id: "456e7890-e89b-12d3-a456-426614174001" + title: "My First Post" + content: "This is my first post content" + author_id: "123e4567-e89b-12d3-a456-426614174000" + tags: ["introduction", "first-post"] + created_at: "2023-01-01T12:00:00Z" + updated_at: "2023-01-01T12:00:00Z" + + headers: + X-Rate-Limit: + description: Rate limit information + schema: + type: integer + minimum: 0 + + securitySchemes: + BearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: JWT Bearer token authentication + +security: + - BearerAuth: [] diff --git a/overlay/cmd/README.md b/overlay/cmd/README.md index dd7f60c..88a48d5 100644 --- a/overlay/cmd/README.md +++ b/overlay/cmd/README.md @@ -1,51 +1,158 @@ -# TEMPORARY README FOR COMMANDS UNTIL CLI IS REINSTATED +# Overlay Commands -# Installation +Commands for working with OpenAPI Overlays. -Install it with the `go install` command: +OpenAPI Overlays provide a way to modify OpenAPI and Arazzo specifications without directly editing the original files. This is useful for adding vendor-specific extensions, modifying specifications for different environments, and applying transformations to third-party APIs. -```sh -go install github.com/speakeasy-api/openapi-overlay@latest -``` +## Available Commands + +### `apply` + +Apply an overlay to an OpenAPI specification. -# Usage +```bash +# Apply overlay to a specification +openapi overlay apply --overlay overlay.yaml --schema spec.yaml -The tool provides sub-commands such as `apply`, `validate` and `compare` under the `openapi-overlay` command for working with overlay files. +# Apply overlay with output to file +openapi overlay apply --overlay overlay.yaml --schema spec.yaml --out modified-spec.yaml -The recommended usage pattern is through Speakeasy CLI command `speakeasy overlay`. Please see [here](https://www.speakeasyapi.dev/docs/speakeasy-cli/overlay/README) for CLI installation and usage documentation. +# Apply overlay when overlay has extends key set +openapi overlay apply --overlay overlay.yaml +``` + +Features: -However, the `openapi-overlay` tool can be used standalone. +- Applies overlay transformations to OpenAPI specifications +- Supports all OpenAPI Overlay Specification operations +- Handles complex nested modifications +- Preserves original document structure where not modified -For more examples of usage, see [here](https://www.speakeasyapi.dev/docs/openapi/overlays) +### `validate` -## Apply +Validate an overlay file for compliance with the OpenAPI Overlay Specification. -The most obvious use-case for this command is applying an overlay to a specification file. +```bash +# Validate an overlay file +openapi overlay validate --overlay overlay.yaml -```sh -openapi-overlay apply --overlay=overlay.yaml --schema=spec.yaml +# Validate with verbose output +openapi overlay validate -v --overlay overlay.yaml ``` -If the overlay file has the `extends` key set to a `file://` URL, then the `spec.yaml` file may be omitted. +This command checks for: + +- Structural validity according to the OpenAPI Overlay Specification +- Required fields and valid values +- Proper overlay operation syntax +- Target path validity -## Validate +Note: This validates the overlay file structure itself, not whether it will apply correctly to a specific OpenAPI specification. -A command is provided to perform basic validation of the overlay file itself. It will not tell you whether it will apply correctly or whether the application will generate a valid OpenAPI specification. Rather, it is limited to just telling you when the spec follows the OpenAPI Overlay Specification correctly: all required fields are present and have valid values. +### `compare` -```sh -openapi-overlay validate --overlay=overlay.yaml +Generate an OpenAPI Overlay specification from two input files. + +```bash +# Generate overlay from two specifications +openapi overlay compare --before spec1.yaml --after spec2.yaml --out overlay.yaml + +# Generate overlay with console output +openapi overlay compare --before spec1.yaml --after spec2.yaml ``` -## Compare +Features: + +- Automatically detects differences between specifications +- Generates overlay operations for all changes +- Provides diagnostic output showing detected changes +- Creates overlay files that can recreate the transformation + +## What are OpenAPI Overlays? + +OpenAPI Overlays are documents that describe modifications to be applied to OpenAPI specifications. They allow you to: + +- **Add vendor extensions** without modifying the original spec +- **Modify specifications** for different environments (dev, staging, prod) +- **Apply transformations** to third-party APIs you don't control +- **Version control changes** separately from the base specification + +### Example Overlay + +```yaml +overlay: 1.0.0 +info: + title: Add API Key Authentication + version: 1.0.0 +actions: + - target: "$.components" + update: + securitySchemes: + ApiKeyAuth: + type: apiKey + in: header + name: X-API-Key + - target: "$.security" + update: + - ApiKeyAuth: [] +``` + +## Common Use Cases + +**Environment Configuration**: Different server URLs, authentication methods per environment +**Vendor Extensions**: Add custom extensions without modifying the original specification +**API Customization**: Modify third-party API specifications for your specific needs +**Documentation Enhancement**: Add examples, descriptions, or additional metadata +**Security Modifications**: Add or modify authentication and authorization schemes -Finally, a tool is provided that will generate an OpenAPI Overlay specification from two input files. +## Overlay Operations -```sh -openapi-overlay compare --before=spec1.yaml --after=spec2.yaml --out=overlay.yaml +OpenAPI Overlays support several types of operations: + +- **Update**: Merge new content with existing content +- **Remove**: Delete specific elements from the specification +- **Replace**: Completely replace existing content with new content + +## Common Options + +All commands support these common options: + +- `-h, --help`: Show help for the command +- `-v, --verbose`: Enable verbose output (global flag) +- `--overlay`: Path to the overlay file +- `--schema`: Path to the OpenAPI specification (for apply command) +- `--out`: Output file path (optional, defaults to stdout) + +## Output Formats + +All commands work with both YAML and JSON input files, but always output YAML at this time. The tools preserve the structure and formatting of the original documents where possible. + +## Examples + +### Basic Workflow +```bash +# Create an overlay by comparing two specs +openapi overlay compare --before original.yaml --after modified.yaml --out changes.overlay.yaml + +# Validate the generated overlay +openapi overlay validate --overlay changes.overlay.yaml + +# Apply the overlay to the original spec +openapi overlay apply --overlay changes.overlay.yaml --schema original.yaml --out final.yaml ``` -the overlay file will be written to a file called `overlay.yaml` with a diagnostic output in the console. +### Environment-Specific Modifications +```bash +# Apply production overlay +openapi overlay apply --overlay prod.overlay.yaml --schema base-spec.yaml --out prod-spec.yaml -# Other Notes +# Apply development overlay +openapi overlay apply --overlay dev.overlay.yaml --schema base-spec.yaml --out dev-spec.yaml +``` -This tool works with either YAML or JSON input files, but always outputs YAML at this time. \ No newline at end of file +### Integration with Other Commands +```bash +# Validate base spec, apply overlay, then validate result +openapi openapi validate ./base-spec.yaml +openapi overlay apply --overlay ./modifications.yaml --schema ./base-spec.yaml --out ./modified-spec.yaml +openapi openapi validate ./modified-spec.yaml \ No newline at end of file From 73a633fe2daac95d61d986eaa676badd4191672c Mon Sep 17 00:00:00 2001 From: Tristan Cartledge Date: Tue, 19 Aug 2025 12:48:33 +1000 Subject: [PATCH 4/5] fix: README --- README.md | 36 +++++++++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index ef096fb..bed5e9b 100644 --- a/README.md +++ b/README.md @@ -22,14 +22,40 @@

+ + - - Release + + OpenAPI Support + + Arazzo Support Go Doc -
- GitHub Action: Test +
+ + Release + Go Report Card - Software License + + Security + + GitHub Action: CI +
+ + Go Version + + Platform Support + + GitHub stars +
+ + CLI Tool + + Go Install +
+ + + + Software License

From 7725005fc801eb7fa347ccf5033f2ccce39917a2 Mon Sep 17 00:00:00 2001 From: Tristan Cartledge Date: Tue, 19 Aug 2025 12:57:13 +1000 Subject: [PATCH 5/5] fix: restore GitHub workflows to main branch versions --- .github/workflows/ci.yaml | 4 ++-- .github/workflows/commits.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index eaabf33..cfc5bd0 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -11,7 +11,7 @@ jobs: name: Lint and Format Check runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install mise uses: jdx/mise-action@v2 @@ -45,7 +45,7 @@ jobs: pull-requests: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install mise uses: jdx/mise-action@v2 diff --git a/.github/workflows/commits.yml b/.github/workflows/commits.yml index e0a68e3..62ad13c 100644 --- a/.github/workflows/commits.yml +++ b/.github/workflows/commits.yml @@ -13,8 +13,8 @@ jobs: name: Conventional Commits runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: webiny/action-conventional-commits@v1.3.0 - - uses: amannn/action-semantic-pull-request@v5.5.3 + - uses: amannn/action-semantic-pull-request@v6.0.1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}