From f85f266e97de5a91cb69a8a308ab6a47104cb68f Mon Sep 17 00:00:00 2001 From: "Paul B." Date: Wed, 13 Dec 2023 18:51:21 +0100 Subject: [PATCH 1/3] cmd: adding an 'overlay' command to be able to apply OAS overlays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is a work in progress.. but I have troubles to import the lib from https://github.com/lornajane/openapi-overlays-js and I don't understand why 🤯 --- README.md | 4 + examples/valid/overlay.yaml | 32 ++++ package-lock.json | 345 ++++++++++++++++++++++++++++++++-- package.json | 4 + src/args.ts | 8 +- src/commands/overlay.ts | 65 +++++++ src/core/overlay.ts | 74 ++++++++ src/definition.ts | 77 +++++++- src/flags.ts | 6 + src/index.ts | 3 +- test/commands/overlay.test.ts | 54 ++++++ test/unit/definition.test.ts | 255 ++++++++++++++++--------- typings.d.ts | 3 + 13 files changed, 819 insertions(+), 111 deletions(-) create mode 100644 examples/valid/overlay.yaml create mode 100644 src/commands/overlay.ts create mode 100644 src/core/overlay.ts create mode 100644 test/commands/overlay.test.ts diff --git a/README.md b/README.md index c6a74365..8ce6f23b 100644 --- a/README.md +++ b/README.md @@ -283,6 +283,10 @@ We currently support [OpenAPI](https://github.com/OAI/OpenAPI-Specification) fro Bug reports and pull requests are welcome on GitHub at . This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct. +## Thanks + +- [Lorna Mitchel](https://github.com/lornajane/) for [openapi-overlay-js](https://github.com/lornajane/openapi-overlays-js) + ## License The Bump CLI project is released under the [MIT License](http://opensource.org/licenses/MIT). diff --git a/examples/valid/overlay.yaml b/examples/valid/overlay.yaml new file mode 100644 index 00000000..a4ff582a --- /dev/null +++ b/examples/valid/overlay.yaml @@ -0,0 +1,32 @@ +overlay: 1.0.0 +info: + title: Overlay to customise API for Protect Earth + version: 0.0.1 +actions: + - target: '$.info.description' + description: Provide a better introduction for our end users than this techno babble. + update: | + Protect Earth's Tree Tracker API will let you see what we've been planting and restoring all + around the UK, and help support our work by directly funding the trees we plant or the sites + we restore. + + To get involved [contact us and ask for an access token](https://protect.earth/contact) then + [check out the API documentation](https://protect.earth/api). + + - target: '$.info' + description: Let's have the public contact general support instead of whoever happened to release this API. + update: + contact: + name: Protect Earth Support + url: https://protect.earth/contact + email: help@protect.earth + + - target: '$.servers.*' + description: Remove all other servers so we can add our own. + remove: true + + - target: '$.servers' + description: Pop our server into the empty server array. + update: + - description: Production + url: https://api.protect.earth/ diff --git a/package-lock.json b/package-lock.json index cfa867b7..71a61a54 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "bump-cli", - "version": "2.7.1", + "version": "2.7.2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "bump-cli", - "version": "2.7.1", + "version": "2.7.2", "license": "MIT", "dependencies": { "@apidevtools/json-schema-ref-parser": "^9.0.7", @@ -17,9 +17,12 @@ "@oclif/core": "1.20.4", "@oclif/plugin-help": "^5.1.10", "@oclif/plugin-warn-if-update-available": "^2.0.36", + "@stoplight/yaml": "^4.2.3", "async-mutex": "^0.4.0", "axios": "^0.27.2", "debug": "^4.3.1", + "jsonpath": "^1.1.1", + "mergician": "^1.0.3", "oas-schemas": "git+https://git@github.com/OAI/OpenAPI-Specification.git#0f9d3ec7c033fef184ec54e1ffc201b2d61ce023", "tslib": "^2.3.0" }, @@ -30,6 +33,7 @@ "@oclif/dev-cli": "^1.26.0", "@oclif/test": "^2.0.3", "@types/debug": "^4.1.5", + "@types/jsonpath": "^0.2.4", "@types/mocha": "^10.0.0", "@types/node": "^20.7.0", "@typescript-eslint/eslint-plugin": "^5.21.0", @@ -1599,6 +1603,45 @@ "integrity": "sha512-+iTbntw2IZPb/anVDbypzfQa+ay64MW0Zo8aJ8gZPWMMK6/OubMVb6lUPMagqjOPnmtauXnFCACVl3O7ogjeqQ==", "dev": true }, + "node_modules/@stoplight/ordered-object-literal": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@stoplight/ordered-object-literal/-/ordered-object-literal-1.0.5.tgz", + "integrity": "sha512-COTiuCU5bgMUtbIFBuyyh2/yVVzlr5Om0v5utQDgBCuQUOPgU1DwoffkTfg4UBQOvByi5foF4w4T+H9CoRe5wg==", + "engines": { + "node": ">=8" + } + }, + "node_modules/@stoplight/types": { + "version": "13.20.0", + "resolved": "https://registry.npmjs.org/@stoplight/types/-/types-13.20.0.tgz", + "integrity": "sha512-2FNTv05If7ib79VPDA/r9eUet76jewXFH2y2K5vuge6SXbRHtWBhcaRmu+6QpF4/WRNoJj5XYRSwLGXDxysBGA==", + "dependencies": { + "@types/json-schema": "^7.0.4", + "utility-types": "^3.10.0" + }, + "engines": { + "node": "^12.20 || >=14.13" + } + }, + "node_modules/@stoplight/yaml": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@stoplight/yaml/-/yaml-4.2.3.tgz", + "integrity": "sha512-Mx01wjRAR9C7yLMUyYFTfbUf5DimEpHMkRDQ1PKLe9dfNILbgdxyrncsOXM3vCpsQ1Hfj4bPiGl+u4u6e9Akqw==", + "dependencies": { + "@stoplight/ordered-object-literal": "^1.0.1", + "@stoplight/types": "^13.0.0", + "@stoplight/yaml-ast-parser": "0.0.48", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=10.8" + } + }, + "node_modules/@stoplight/yaml-ast-parser": { + "version": "0.0.48", + "resolved": "https://registry.npmjs.org/@stoplight/yaml-ast-parser/-/yaml-ast-parser-0.0.48.tgz", + "integrity": "sha512-sV+51I7WYnLJnKPn2EMWgS4EUfoP4iWEbrWwbXsj0MZCB/xOK8j6+C9fntIdOM50kpx45ZLC3s6kwKivWuqvyg==" + }, "node_modules/@szmarczak/http-timer": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", @@ -1693,6 +1736,12 @@ "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz", "integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==" }, + "node_modules/@types/jsonpath": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@types/jsonpath/-/jsonpath-0.2.4.tgz", + "integrity": "sha512-K3hxB8Blw0qgW6ExKgMbXQv2UPZBoE2GqLpVY+yr7nMD2Pq86lsuIzyAaiQ7eMqFL5B6di6pxSkogLJEyEHoGA==", + "dev": true + }, "node_modules/@types/keyv": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", @@ -3073,8 +3122,7 @@ "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==" }, "node_modules/default-require-extensions": { "version": "3.0.0", @@ -3277,6 +3325,83 @@ "node": ">=0.8.0" } }, + "node_modules/escodegen": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-1.14.3.tgz", + "integrity": "sha512-qFcX0XJkdg+PB3xjZZG/wKSuT1PnQWx57+TVSjIMmILd2yC/6ByYElPwJnslDsuWuSAp4AwJGumarAAmJch5Kw==", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^4.2.0", + "esutils": "^2.0.2", + "optionator": "^0.8.1" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=4.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/escodegen/node_modules/levn": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", + "integrity": "sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==", + "dependencies": { + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/escodegen/node_modules/optionator": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz", + "integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==", + "dependencies": { + "deep-is": "~0.1.3", + "fast-levenshtein": "~2.0.6", + "levn": "~0.3.0", + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2", + "word-wrap": "~1.2.3" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/escodegen/node_modules/prelude-ls": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", + "integrity": "sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/escodegen/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/escodegen/node_modules/type-check": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", + "integrity": "sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==", + "dependencies": { + "prelude-ls": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/eslint": { "version": "8.45.0", "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.45.0.tgz", @@ -3601,7 +3726,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "dev": true, "engines": { "node": ">=4.0" } @@ -3610,7 +3734,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -3778,8 +3901,7 @@ "node_modules/fast-levenshtein": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==" }, "node_modules/fastq": { "version": "1.11.0", @@ -5565,6 +5687,28 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jsonpath": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/jsonpath/-/jsonpath-1.1.1.tgz", + "integrity": "sha512-l6Cg7jRpixfbgoWgkrl77dgEj8RPvND0wMH6TwQmi9Qs4TFfS9u5cUFnbeKTwj5ga5Y3BTGGNI28k117LJ009w==", + "dependencies": { + "esprima": "1.2.2", + "static-eval": "2.0.2", + "underscore": "1.12.1" + } + }, + "node_modules/jsonpath/node_modules/esprima": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-1.2.2.tgz", + "integrity": "sha512-+JpPZam9w5DuJ3Q67SqsMGtiHKENSMRVoxvArfJZK01/BfLEObtZ6orJa/MtoGNR/rfMgp5837T41PAmTwAv/A==", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/just-extend": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-4.2.1.tgz", @@ -6181,6 +6325,14 @@ "node": ">= 8" } }, + "node_modules/mergician": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/mergician/-/mergician-1.1.0.tgz", + "integrity": "sha512-FXbxzU6BBhGkV8XtUr8Sk015ZRaAALviit8Lle6OEgd1udX8wlu6tBeUMLGQGdz1MfHpAVNNQkXowyDnJuhXpA==", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/micromatch": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.4.tgz", @@ -8766,6 +8918,14 @@ "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=" }, + "node_modules/static-eval": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/static-eval/-/static-eval-2.0.2.tgz", + "integrity": "sha512-N/D219Hcr2bPjLxPiV+TQE++Tsmrady7TqAJugLy7Xk1EumfDWS/f5dtBbkRCGE7wKKXuYockQoj8Rm2/pVKyg==", + "dependencies": { + "escodegen": "^1.8.1" + } + }, "node_modules/stdout-stderr": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/stdout-stderr/-/stdout-stderr-0.1.13.tgz", @@ -9200,6 +9360,11 @@ "node": ">=4.2.0" } }, + "node_modules/underscore": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.12.1.tgz", + "integrity": "sha512-hEQt0+ZLDVUMhebKxL4x1BTtDY7bavVofhZ9KZ4aI26X9SRaE+Y3m83XUL1UP2jn8ynjndwCCpEHdUG+9pP1Tw==" + }, "node_modules/unique-string": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", @@ -9385,6 +9550,14 @@ "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", "dev": true }, + "node_modules/utility-types": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.10.0.tgz", + "integrity": "sha512-O11mqxmi7wMKCo6HKFt5AhO4BwY3VV68YU07tgxfz8zJTIxr4BpsezN49Ffwy9j3ZpwwJp4fkRwjRzq3uWE6Rg==", + "engines": { + "node": ">= 4" + } + }, "node_modules/uuid": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", @@ -9460,6 +9633,14 @@ "node": ">=8" } }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wordwrap": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", @@ -10936,6 +11117,36 @@ "integrity": "sha512-+iTbntw2IZPb/anVDbypzfQa+ay64MW0Zo8aJ8gZPWMMK6/OubMVb6lUPMagqjOPnmtauXnFCACVl3O7ogjeqQ==", "dev": true }, + "@stoplight/ordered-object-literal": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@stoplight/ordered-object-literal/-/ordered-object-literal-1.0.5.tgz", + "integrity": "sha512-COTiuCU5bgMUtbIFBuyyh2/yVVzlr5Om0v5utQDgBCuQUOPgU1DwoffkTfg4UBQOvByi5foF4w4T+H9CoRe5wg==" + }, + "@stoplight/types": { + "version": "13.20.0", + "resolved": "https://registry.npmjs.org/@stoplight/types/-/types-13.20.0.tgz", + "integrity": "sha512-2FNTv05If7ib79VPDA/r9eUet76jewXFH2y2K5vuge6SXbRHtWBhcaRmu+6QpF4/WRNoJj5XYRSwLGXDxysBGA==", + "requires": { + "@types/json-schema": "^7.0.4", + "utility-types": "^3.10.0" + } + }, + "@stoplight/yaml": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@stoplight/yaml/-/yaml-4.2.3.tgz", + "integrity": "sha512-Mx01wjRAR9C7yLMUyYFTfbUf5DimEpHMkRDQ1PKLe9dfNILbgdxyrncsOXM3vCpsQ1Hfj4bPiGl+u4u6e9Akqw==", + "requires": { + "@stoplight/ordered-object-literal": "^1.0.1", + "@stoplight/types": "^13.0.0", + "@stoplight/yaml-ast-parser": "0.0.48", + "tslib": "^2.2.0" + } + }, + "@stoplight/yaml-ast-parser": { + "version": "0.0.48", + "resolved": "https://registry.npmjs.org/@stoplight/yaml-ast-parser/-/yaml-ast-parser-0.0.48.tgz", + "integrity": "sha512-sV+51I7WYnLJnKPn2EMWgS4EUfoP4iWEbrWwbXsj0MZCB/xOK8j6+C9fntIdOM50kpx45ZLC3s6kwKivWuqvyg==" + }, "@szmarczak/http-timer": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", @@ -11027,6 +11238,12 @@ "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz", "integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==" }, + "@types/jsonpath": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@types/jsonpath/-/jsonpath-0.2.4.tgz", + "integrity": "sha512-K3hxB8Blw0qgW6ExKgMbXQv2UPZBoE2GqLpVY+yr7nMD2Pq86lsuIzyAaiQ7eMqFL5B6di6pxSkogLJEyEHoGA==", + "dev": true + }, "@types/keyv": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", @@ -12038,8 +12255,7 @@ "deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==" }, "default-require-extensions": { "version": "3.0.0", @@ -12188,6 +12404,61 @@ "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", "dev": true }, + "escodegen": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-1.14.3.tgz", + "integrity": "sha512-qFcX0XJkdg+PB3xjZZG/wKSuT1PnQWx57+TVSjIMmILd2yC/6ByYElPwJnslDsuWuSAp4AwJGumarAAmJch5Kw==", + "requires": { + "esprima": "^4.0.1", + "estraverse": "^4.2.0", + "esutils": "^2.0.2", + "optionator": "^0.8.1", + "source-map": "~0.6.1" + }, + "dependencies": { + "levn": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", + "integrity": "sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==", + "requires": { + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2" + } + }, + "optionator": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz", + "integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==", + "requires": { + "deep-is": "~0.1.3", + "fast-levenshtein": "~2.0.6", + "levn": "~0.3.0", + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2", + "word-wrap": "~1.2.3" + } + }, + "prelude-ls": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", + "integrity": "sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==" + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "optional": true + }, + "type-check": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", + "integrity": "sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==", + "requires": { + "prelude-ls": "~1.1.2" + } + } + } + }, "eslint": { "version": "8.45.0", "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.45.0.tgz", @@ -12415,14 +12686,12 @@ "estraverse": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "dev": true + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==" }, "esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==" }, "execa": { "version": "0.10.0", @@ -12556,8 +12825,7 @@ "fast-levenshtein": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==" }, "fastq": { "version": "1.11.0", @@ -13872,6 +14140,23 @@ "graceful-fs": "^4.1.6" } }, + "jsonpath": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/jsonpath/-/jsonpath-1.1.1.tgz", + "integrity": "sha512-l6Cg7jRpixfbgoWgkrl77dgEj8RPvND0wMH6TwQmi9Qs4TFfS9u5cUFnbeKTwj5ga5Y3BTGGNI28k117LJ009w==", + "requires": { + "esprima": "1.2.2", + "static-eval": "2.0.2", + "underscore": "1.12.1" + }, + "dependencies": { + "esprima": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-1.2.2.tgz", + "integrity": "sha512-+JpPZam9w5DuJ3Q67SqsMGtiHKENSMRVoxvArfJZK01/BfLEObtZ6orJa/MtoGNR/rfMgp5837T41PAmTwAv/A==" + } + } + }, "just-extend": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-4.2.1.tgz", @@ -14346,6 +14631,11 @@ "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==" }, + "mergician": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/mergician/-/mergician-1.1.0.tgz", + "integrity": "sha512-FXbxzU6BBhGkV8XtUr8Sk015ZRaAALviit8Lle6OEgd1udX8wlu6tBeUMLGQGdz1MfHpAVNNQkXowyDnJuhXpA==" + }, "micromatch": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.4.tgz", @@ -16287,6 +16577,14 @@ "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=" }, + "static-eval": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/static-eval/-/static-eval-2.0.2.tgz", + "integrity": "sha512-N/D219Hcr2bPjLxPiV+TQE++Tsmrady7TqAJugLy7Xk1EumfDWS/f5dtBbkRCGE7wKKXuYockQoj8Rm2/pVKyg==", + "requires": { + "escodegen": "^1.8.1" + } + }, "stdout-stderr": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/stdout-stderr/-/stdout-stderr-0.1.13.tgz", @@ -16595,6 +16893,11 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.5.5.tgz", "integrity": "sha512-TCTIul70LyWe6IJWT8QSYeA54WQe8EjQFU4wY52Fasj5UKx88LNYKCgBEHcOMOrFF1rKGbD8v/xcNWVUq9SymA==" }, + "underscore": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.12.1.tgz", + "integrity": "sha512-hEQt0+ZLDVUMhebKxL4x1BTtDY7bavVofhZ9KZ4aI26X9SRaE+Y3m83XUL1UP2jn8ynjndwCCpEHdUG+9pP1Tw==" + }, "unique-string": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", @@ -16731,6 +17034,11 @@ "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", "dev": true }, + "utility-types": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.10.0.tgz", + "integrity": "sha512-O11mqxmi7wMKCo6HKFt5AhO4BwY3VV68YU07tgxfz8zJTIxr4BpsezN49Ffwy9j3ZpwwJp4fkRwjRzq3uWE6Rg==" + }, "uuid": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", @@ -16790,6 +17098,11 @@ "string-width": "^4.0.0" } }, + "word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==" + }, "wordwrap": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", diff --git a/package.json b/package.json index 24dbb64d..16aaa214 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "@oclif/dev-cli": "^1.26.0", "@oclif/test": "^2.0.3", "@types/debug": "^4.1.5", + "@types/jsonpath": "^0.2.4", "@types/mocha": "^10.0.0", "@types/node": "^20.7.0", "@typescript-eslint/eslint-plugin": "^5.21.0", @@ -89,9 +90,12 @@ "@oclif/core": "1.20.4", "@oclif/plugin-help": "^5.1.10", "@oclif/plugin-warn-if-update-available": "^2.0.36", + "@stoplight/yaml": "^4.2.3", "async-mutex": "^0.4.0", "axios": "^0.27.2", "debug": "^4.3.1", + "jsonpath": "^1.1.1", + "mergician": "^1.0.3", "oas-schemas": "git+https://git@github.com/OAI/OpenAPI-Specification.git#0f9d3ec7c033fef184ec54e1ffc201b2d61ce023", "tslib": "^2.3.0" } diff --git a/src/args.ts b/src/args.ts index 9e55f40e..6acfa6e0 100644 --- a/src/args.ts +++ b/src/args.ts @@ -10,4 +10,10 @@ const otherFileArg = { description: 'Path or URL to a second API documentation file to compute its diff', }; -export { fileArg, otherFileArg }; +const overlayFileArg = { + name: 'OVERLAY_FILE', + required: true, + description: 'Path or URL to an overlay file', +}; + +export { fileArg, otherFileArg, overlayFileArg }; diff --git a/src/commands/overlay.ts b/src/commands/overlay.ts new file mode 100644 index 00000000..bed7448a --- /dev/null +++ b/src/commands/overlay.ts @@ -0,0 +1,65 @@ +import chalk from 'chalk'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { dirname } from 'path'; + +import { API } from '../definition'; +import { confirm as promptConfirm } from '../core/utils/prompts'; +import Command from '../command'; +import * as flagsBuilder from '../flags'; +import { fileArg, overlayFileArg } from '../args'; +import { cli } from '../cli'; + +export default class Overlay extends Command { + static description = 'Apply an OpenAPI specified overlay to your API definition.'; + + static examples = [ + `Apply the OVERLAY_FILE to the existing DEFINITION_FILE. The resulting +definition is output on stdout meaning you can redirect it to a new +file. + +${chalk.dim('$ bump overlay DEFINITION_FILE OVERLAY_FILE > destination/file.json')} +* Let's apply the overlay to the main definition... done +`, + ]; + + static flags = { + help: flagsBuilder.help({ char: 'h' }), + out: flagsBuilder.out(), + }; + + static args = [fileArg, overlayFileArg]; + + async run(): Promise { + const { args, flags } = this.parse(Overlay); + const outputPath = flags.out; + + cli.action.start("* Let's apply the overlay to the main definition"); + + const api = await API.load(args.FILE); + + await api.applyOverlay(args.OVERLAY_FILE); + const [overlayedDefinition] = api.extractDefinition(outputPath); + + cli.action.stop(); + + if (outputPath) { + await mkdir(dirname(outputPath), { recursive: true }); + let confirm = true; + if (existsSync(outputPath)) { + await promptConfirm( + `Do you want to override the existing destination file? (${outputPath})`, + ).catch(() => { + confirm = false; + }); + } + if (confirm) { + await writeFile(outputPath, overlayedDefinition); + } + } else { + cli.log(overlayedDefinition); + } + + return; + } +} diff --git a/src/core/overlay.ts b/src/core/overlay.ts new file mode 100644 index 00000000..e9002b1d --- /dev/null +++ b/src/core/overlay.ts @@ -0,0 +1,74 @@ +import debug from 'debug'; +import jsonpath from 'jsonpath'; +import mergician from 'mergician'; +import { JSONSchema4Object } from 'json-schema'; + +import { APIDefinition, OpenAPIOverlay } from '../definition'; + +export class Overlay { + // WIP @github.com/lornajane/openapi-overlays-js + // + // I couldn't get the upstream lib to be imported properly due to + // some issues with ESM module imports so this is method was copied + // from github.com/lornajane/openapi-overlays-js and has been + // adapted to make our Typescript build happy. + // + // If you make any changes here, PLEASE ALSO MAKE THEM UPSTREAM. + public run(spec: APIDefinition, overlay: OpenAPIOverlay): APIDefinition { + // Use jsonpath.apply to do the changes + if (overlay.actions && overlay.actions.length >= 1) + overlay.actions.forEach((a) => { + const action = a as JSONSchema4Object; + if (!action.target) { + process.stderr.write('Action with a missing target\n'); + return; + } + const target = action.target as string; + // Is it a remove? + if (action.hasOwnProperty('remove')) { + while (true) { + const path = jsonpath.paths(spec, target, 1); + if (path.length == 0) { + break; + } + const parent = jsonpath.parent(spec, target); + const thingToRemove = path[0][path[0].length - 1]; + if (Array.isArray(parent)) { + parent.splice(thingToRemove as number, 1); + } else { + delete parent[thingToRemove]; + } + } + } else { + try { + // It must be an update + jsonpath.apply(spec, target, (chunk) => { + if (typeof chunk === 'object' && typeof action.update === 'object') { + if (Array.isArray(chunk) && Array.isArray(action.update)) { + return chunk.concat(action.update); + } else { + // Deep merge objects using a module (built-in spread operator is only shallow) + const merger = mergician({ appendArrays: true }); + return merger(chunk, action.update); + } + } else { + return action.update; + } + }); + } catch (ex) { + process.stderr.write(`Error applying overlay: ${(ex as Error).message}\n`); + //return chunk + } + } + }); + + return spec; + } + + // Function signature type taken from @types/debug + // Debugger(formatter: any, ...args: any[]): void; + /* eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any */ + d(formatter: any, ...args: any[]): void { + return debug(`bump-cli:core:overlay`)(formatter, ...args); + } +} diff --git a/src/definition.ts b/src/definition.ts index e1348c2a..f74ae7fa 100644 --- a/src/definition.ts +++ b/src/definition.ts @@ -5,11 +5,15 @@ import asyncapi from '@asyncapi/specs'; import { JSONSchema4, JSONSchema4Object, + JSONSchema4Array, JSONSchema6, JSONSchema6Object, JSONSchema7, } from 'json-schema'; import path from 'path'; +import { safeStringify } from '@stoplight/yaml'; + +import { Overlay } from './core/overlay'; type SpecSchema = JSONSchema4 | JSONSchema6 | JSONSchema7; @@ -48,6 +52,7 @@ class API { readonly location: string; readonly rawDefinition: string; readonly definition: APIDefinition; + overlayedDefinition: APIDefinition | undefined; readonly references: APIReference[]; readonly version: string; readonly specName: string; @@ -73,6 +78,8 @@ class API { getSpec(definition: APIDefinition): SpecSchema { if (API.isAsyncAPI(definition)) { return SupportedFormat.asyncapi[this.versionWithoutPatch()]; + } else if (API.isOpenAPIOverlay(definition)) { + return { overlay: { type: 'string' } }; } else { return SupportedFormat.openapi[this.versionWithoutPatch()]; } @@ -94,6 +101,10 @@ class API { } } + guessFormat(output?: string): string { + return (output || this.location).endsWith('.json') ? 'json' : 'yaml'; + } + versionWithoutPatch(): string { const [major, minor] = this.version.split('.', 3); @@ -156,13 +167,33 @@ class API { ); } - if (!API.isOpenAPI(parsed) && !API.isAsyncAPI(parsed)) { + if ( + !API.isOpenAPI(parsed) && + !API.isAsyncAPI(parsed) && + !API.isOpenAPIOverlay(parsed) + ) { throw new UnsupportedFormat(); } return [raw, parsed]; } + serializeDefinition(outputPath?: string): string { + if (this.overlayedDefinition) { + let serializedDefinition: string; + + if (this.guessFormat(outputPath) == 'json') { + serializedDefinition = JSON.stringify(this.overlayedDefinition); + } else { + serializedDefinition = safeStringify(this.overlayedDefinition); + } + + return serializedDefinition; + } else { + return this.rawDefinition; + } + } + static isOpenAPI( definition: JSONSchema4Object | JSONSchema6Object, ): definition is OpenAPI { @@ -177,7 +208,13 @@ class API { return 'asyncapi' in definition; } - public extractDefinition(): [string, APIReference[]] { + static isOpenAPIOverlay( + definition: JSONSchema4Object | JSONSchema6Object, + ): definition is OpenAPIOverlay { + return 'overlay' in definition; + } + + public extractDefinition(outputPath?: string): [string, APIReference[]] { const references = []; for (let i = 0; i < this.references.length; i++) { @@ -188,7 +225,21 @@ class API { }); } - return [this.rawDefinition, references]; + return [this.serializeDefinition(outputPath), references]; + } + + public async applyOverlay(overlayPath: string): Promise { + const overlay = await API.load(overlayPath); + const overlayDefinition = overlay.definition; + + if (!API.isOpenAPIOverlay(overlayDefinition)) { + throw new Error(`${overlayPath} does not look like an OpenAPI overlay`); + } + + this.overlayedDefinition = await new Overlay().run( + this.definition, + overlayDefinition, + ); } static async load(path: string): Promise { @@ -245,19 +296,31 @@ type APIReference = { content: string; }; -type APIDefinition = OpenAPI | AsyncAPI; +type APIDefinition = OpenAPI | AsyncAPI | OpenAPIOverlay; + +type InfoObject = { + readonly title: string; + readonly version: string; + readonly description?: string; +}; // http://spec.openapis.org/oas/v3.1.0#oasObject type OpenAPI = JSONSchema4Object & { readonly openapi?: string; readonly swagger?: string; - readonly info: string; + readonly info: InfoObject; +}; + +type OpenAPIOverlay = JSONSchema4Object & { + readonly overlay: string; + readonly info: InfoObject; + readonly actions: JSONSchema4Array; }; // https://www.asyncapi.com/docs/specifications/2.0.0#A2SObject type AsyncAPI = JSONSchema4Object & { readonly asyncapi: string; - readonly info: string; + readonly info: InfoObject; }; -export { API, SupportedFormat }; +export { API, APIDefinition, OpenAPIOverlay, SupportedFormat }; diff --git a/src/flags.ts b/src/flags.ts index cc1f1f32..1b7fd2c0 100644 --- a/src/flags.ts +++ b/src/flags.ts @@ -128,6 +128,11 @@ const expires = flags.build({ "Specify a longer expiration date for public diffs (defaults to 1 day). Use iso8601 format to provide a date, or you can use `--expires 'never'` to keep the result live indefinitely.", }); +const out = flags.build({ + char: 'o', + description: 'Output file path', +}); + export { doc, docName, @@ -143,4 +148,5 @@ export { live, format, expires, + out, }; diff --git a/src/index.ts b/src/index.ts index 142fce85..96f27793 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,9 @@ import { run } from '@oclif/command'; import { Diff } from './core/diff'; +import { Overlay } from './core/overlay'; import Deploy from './commands/deploy'; import Preview from './commands/preview'; export { VersionResponse, PreviewResponse, DiffResponse, WithDiff } from './api/models'; -export { run, Deploy, Diff, Preview }; +export { run, Deploy, Diff, Preview, Overlay }; diff --git a/test/commands/overlay.test.ts b/test/commands/overlay.test.ts new file mode 100644 index 00000000..74862024 --- /dev/null +++ b/test/commands/overlay.test.ts @@ -0,0 +1,54 @@ +import base, { expect } from '@oclif/test'; +import { rm } from 'node:fs/promises'; + +const test = base; + +describe('overlay subcommand', () => { + describe('Successfully compute the merged API document with the given overlay', () => { + test + .stdout() + .stderr() + .command([ + 'overlay', + 'examples/valid/openapi.v3.json', + 'examples/valid/overlay.yaml', + ]) + .it('Spits the result to stdout', ({ stdout, stderr }) => { + expect(stderr).to.contain("Let's apply the overlay to the main definition"); + + const overlayedDefinition = JSON.parse(stdout); + + // Target on info description + expect(overlayedDefinition.info.description).to.match( + /Protect Earth's Tree Tracker API/, + ); + + // Target on info contact information + expect(overlayedDefinition.info.contact.email).to.equal('help@protect.earth'); + // Target on all servers + expect(overlayedDefinition.servers.length).to.equal(1); + expect(overlayedDefinition.servers[0].description).to.equal('Production'); + }); + + test + .do(async () => await rm('tmp/openapi.overlayed.json', { force: true })) + .stdout() + .stderr() + .command([ + 'overlay', + 'examples/valid/openapi.v3.json', + 'examples/valid/overlay.yaml', + '--out', + 'tmp/openapi.overlayed.json', + ]) + .it( + 'Stores the result to the target output file argument', + async ({ stdout, stderr }) => { + expect(stderr).to.contain("Let's apply the overlay to the main definition"); + expect(stdout).to.be.empty; + // Cleanup created file + await rm('tmp/openapi.overlayed.json'); + }, + ); + }); +}); diff --git a/test/unit/definition.test.ts b/test/unit/definition.test.ts index 5d8daaab..30c50ee3 100644 --- a/test/unit/definition.test.ts +++ b/test/unit/definition.test.ts @@ -1,122 +1,205 @@ import { expect, test } from '@oclif/test'; -import { API } from '../../src/definition'; +import { API, APIDefinition } from '../../src/definition'; import nock from 'nock'; import path from 'path'; +import * as YAML from '@stoplight/yaml'; nock.disableNetConnect(); -describe('API definition class', () => { - describe('with inexistent file', () => { - test - .do(async () => await API.load('FILE')) - .catch( - (err) => { - expect(err.message).to.match(/Error opening file/); - }, - { raiseIfNotThrown: false }, - ) - .it('throws an error'); - }); +describe('API class', () => { + describe('API.load(..)', () => { + describe('with inexistent file', () => { + test + .do(async () => await API.load('FILE')) + .catch( + (err) => { + expect(err.message).to.match(/Error opening file/); + }, + { raiseIfNotThrown: false }, + ) + .it('throws an error'); + }); + + describe('with no references', () => { + test.it('parses successfully an OpenAPI contract', async () => { + const api = await API.load('examples/valid/openapi.v2.json'); + expect(api.version).to.equal('2.0'); + expect(api.references).to.be.an('array').that.is.empty; + }); + + test.it('parses successfully an AsyncAPI contract', async () => { + const api = await API.load('examples/valid/asyncapi.v2.3.yml'); + expect(api.version).to.equal('2.3.0'); + }); - describe('with no references', () => { - test.it('parses successfully an OpenAPI contract', async () => { - const api = await API.load('examples/valid/openapi.v2.json'); - expect(api.version).to.equal('2.0'); - expect(api.references).to.be.an('array').that.is.empty; + test.it('parses successfully an AsyncAPI 2.4 contract', async () => { + nock.enableNetConnect('raw.githubusercontent.com'); + const api = await API.load( + 'https://raw.githubusercontent.com/asyncapi/spec/v2.4.0/examples/streetlights-kafka.yml', + ); + nock.disableNetConnect(); + expect(api.version).to.equal('2.4.0'); + }); + + test.it('parses successfully an AsyncAPI 2.5 contract', async () => { + const api = await API.load('examples/valid/asyncapi.v2.5.yml'); + expect(api.version).to.equal('2.5.0'); + }); + }); + + describe('with file & http references', () => { + test + .nock('http://example.org', (api) => api.get('/param-lights.json').reply(200, {})) + .it('parses successfully', async () => { + const api = await API.load('examples/valid/asyncapi.v2.yml'); + expect(api.version).to.equal('2.2.0'); + expect(api.references.length).to.equal(5); + const locations = api.references.map((ref) => ref.location); + expect(locations).to.include('http://example.org/param-lights.json'); + + expect(locations).to.include(['params', 'streetlightId.json'].join(path.sep)); + + expect(locations).to.not.include( + ['.', 'params', 'streetlightId.json'].join(path.sep), + ); + + expect(locations).to.include(['doc', 'introduction.md'].join(path.sep)); + }); }); - test.it('parses successfully an AsyncAPI contract', async () => { - const api = await API.load('examples/valid/asyncapi.v2.3.yml'); - expect(api.version).to.equal('2.3.0'); + describe('with a relative descendant file path', () => { + test.it('parses successfully', async () => { + const api = await API.load('./examples/valid/openapi.v2.json'); + expect(api.version).to.equal('2.0'); + }); }); - test.it('parses successfully an AsyncAPI 2.4 contract', async () => { - nock.enableNetConnect('raw.githubusercontent.com'); - const api = await API.load( - 'https://raw.githubusercontent.com/asyncapi/spec/v2.4.0/examples/streetlights-kafka.yml', - ); - nock.disableNetConnect(); - expect(api.version).to.equal('2.4.0'); + describe('with a file path containing special characters', () => { + test.it('parses successfully', async () => { + const api = await API.load('./examples/valid/__gitlab-é__.yml'); + expect(api.version).to.equal('3.0.0'); + }); }); - test.it('parses successfully an AsyncAPI 2.5 contract', async () => { - const api = await API.load('examples/valid/asyncapi.v2.5.yml'); - expect(api.version).to.equal('2.5.0'); + describe('with an http file containing relative URL refs', () => { + test + .nock('http://example.org', (api) => + api + .get('/openapi') + .replyWithFile(200, 'examples/valid/openapi.v3.json', { + 'Content-Type': 'application/json', + }) + .get('/schemas/all.yml') + .replyWithFile(200, 'examples/valid/schemas/all.yml', { + 'Content-Type': 'application/yaml', + }), + ) + .it('parses external file successfully', async () => { + const api = await API.load('http://example.org/openapi'); + expect(api.version).to.equal('3.0.2'); + expect(api.references.map((ref) => ref.location)).to.contain( + ['schemas', 'all.yml'].join(path.sep), + ); + }); + }); + + describe('with an invalid definition file', () => { + for (const [example, error] of Object.entries({ + './examples/invalid/openapi.yml': 'Unsupported API specification', + './examples/invalid/array.yml': 'Unsupported API specification', + './examples/invalid/string.yml': 'Unsupported API specification', + './examples/valid/asyncapi.v3.yml': 'Unsupported API specification', + })) { + test + .do(async () => await API.load(example)) + .catch( + (err) => { + expect(err.message).to.match(new RegExp(error)); + }, + { raiseIfNotThrown: false }, + ) + .it(`throws an error with details about ${example}`); + } }); }); - describe('with file & http references', () => { - test - .nock('http://example.org', (api) => api.get('/param-lights.json').reply(200, {})) - .it('parses successfully', async () => { - const api = await API.load('examples/valid/asyncapi.v2.yml'); - expect(api.version).to.equal('2.2.0'); - expect(api.references.length).to.equal(5); - const locations = api.references.map((ref) => ref.location); - expect(locations).to.include('http://example.org/param-lights.json'); + describe('serializeDefinition()', () => { + describe('with no overlay applied', () => { + test.it('returns the rawDefinition, no matter the argument', async () => { + const api = await API.load('examples/valid/openapi.v2.json'); - expect(locations).to.include(['params', 'streetlightId.json'].join(path.sep)); + expect(api.serializeDefinition()).to.equal(api.rawDefinition); + expect(api.serializeDefinition('destination/file.json')).to.equal( + api.rawDefinition, + ); + }); + }); + + describe('with an overlay applied', () => { + test.it('returns the overlayed definition', async () => { + const api = await API.load('examples/valid/openapi.v2.json'); + await api.applyOverlay('examples/valid/overlay.yaml'); - expect(locations).to.not.include( - ['.', 'params', 'streetlightId.json'].join(path.sep), + expect(api.serializeDefinition()).to.equal( + JSON.stringify(api.overlayedDefinition), ); - expect(locations).to.include(['doc', 'introduction.md'].join(path.sep)); + expect(api.serializeDefinition('destination/file.yaml')).to.equal( + YAML.safeStringify(api.overlayedDefinition), + ); }); - }); - - describe('with a relative descendant file path', () => { - test.it('parses successfully', async () => { - const api = await API.load('./examples/valid/openapi.v2.json'); - expect(api.version).to.equal('2.0'); }); }); - describe('with a file path containing special characters', () => { - test.it('parses successfully', async () => { - const api = await API.load('./examples/valid/__gitlab-é__.yml'); - expect(api.version).to.equal('3.0.0'); - }); - }); + describe('applyOverlay()', () => { + describe('when overlay is valid', () => { + test.it( + 'sets the overlayedDefinition with the given overlay file path', + async () => { + const api = await API.load('examples/valid/openapi.v2.json'); + + expect(api.overlayedDefinition).to.be.undefined; + await api.applyOverlay('examples/valid/overlay.yaml'); + expect(api.overlayedDefinition).to.exist; + expect((api.overlayedDefinition as APIDefinition).info.description).to.match( + /Protect Earth's Tree Tracker API/, + ); + }, + ); - describe('with an http file containing relative URL refs', () => { - test - .nock('http://example.org', (api) => - api - .get('/openapi') - .replyWithFile(200, 'examples/valid/openapi.v3.json', { - 'Content-Type': 'application/json', - }) - .get('/schemas/all.yml') - .replyWithFile(200, 'examples/valid/schemas/all.yml', { + test + .nock('http://example.org', (api) => + api.get('/source.yaml').replyWithFile(200, 'examples/valid/overlay.yaml', { 'Content-Type': 'application/yaml', }), - ) - .it('parses external file successfully', async () => { - const api = await API.load('http://example.org/openapi'); - expect(api.version).to.equal('3.0.2'); - expect(api.references.map((ref) => ref.location)).to.contain( - ['schemas', 'all.yml'].join(path.sep), - ); - }); - }); + ) + .it('sets the overlayedDefinition with the given overlay URL', async () => { + const api = await API.load('examples/valid/openapi.v2.json'); - describe('with an invalid definition file', () => { - for (const [example, error] of Object.entries({ - './examples/invalid/openapi.yml': 'Unsupported API specification', - './examples/invalid/array.yml': 'Unsupported API specification', - './examples/invalid/string.yml': 'Unsupported API specification', - './examples/valid/asyncapi.v3.yml': 'Unsupported API specification', - })) { + expect(api.overlayedDefinition).to.be.undefined; + await api.applyOverlay('http://example.org/source.yaml'); + expect(api.overlayedDefinition).to.exist; + expect((api.overlayedDefinition as APIDefinition).info.description).to.match( + /Protect Earth's Tree Tracker API/, + ); + }); + }); + + describe('when overlay is invalid', () => { test - .do(async () => await API.load(example)) + .do(async () => { + const api = await API.load('examples/valid/openapi.v2.json'); + await api.applyOverlay('examples/valid/openapi.v2.json'); + }) .catch( (err) => { - expect(err.message).to.match(new RegExp(error)); + expect(err.message).to.match( + /examples\/valid\/openapi.v2.json does not look like an OpenAPI overlay/, + ); }, { raiseIfNotThrown: false }, ) - .it(`throws an error with details about ${example}`); - } + .it('throws an error'); + }); }); }); diff --git a/typings.d.ts b/typings.d.ts index 6e4d69fd..5d69ffab 100644 --- a/typings.d.ts +++ b/typings.d.ts @@ -1,6 +1,9 @@ // oas-schemas doesn't define TS types declare module 'oas-schemas'; +// mergician doesn't define TS types +declare module 'mergician'; + // Internals of json-schema-ref-parser doesn't expose types declare module '@apidevtools/json-schema-ref-parser/lib/options'; From 1fa0fdc8dfd3ad872f13867c8c0fd77d0584d049 Mon Sep 17 00:00:00 2001 From: "Paul B." Date: Thu, 14 Mar 2024 17:55:26 +0100 Subject: [PATCH 2/3] deploy: adding an --overlay flag to the deploy command This commit adds an optional `--overlay` command to the existing command `bump deploy` to be ably to apply an overlay on the target API document before deploying. This parameter only works for a single file deploy (not compatible with a DIRECTORY + hub deploy) --- src/commands/deploy.ts | 6 ++++++ src/core/deploy.ts | 4 ++++ src/flags.ts | 6 ++++++ 3 files changed, 16 insertions(+) diff --git a/src/commands/deploy.ts b/src/commands/deploy.ts index 2aa3fa13..ac88a495 100644 --- a/src/commands/deploy.ts +++ b/src/commands/deploy.ts @@ -71,6 +71,7 @@ ${chalk.dim('$ bump deploy FILE --dry-run --doc --token --token --token --token --token { const action = dryRun ? 'validate' : 'deploy'; cli.action.start( @@ -242,6 +247,7 @@ ${chalk.dim('$ bump deploy FILE --dry-run --doc --token { let version: VersionResponse | undefined = undefined; + if (overlay) { + await api.applyOverlay(overlay); + } const [definition, references] = api.extractDefinition(); const request: VersionRequest = { diff --git a/src/flags.ts b/src/flags.ts index 1b7fd2c0..3d1739c7 100644 --- a/src/flags.ts +++ b/src/flags.ts @@ -133,6 +133,11 @@ const out = flags.build({ description: 'Output file path', }); +const overlay = flags.build({ + char: 'o', + description: 'Path or URL of an overlay file to apply before deploying', +}); + export { doc, docName, @@ -149,4 +154,5 @@ export { format, expires, out, + overlay, }; From d0f3a7cceb9fa9fb1b2e6019aeb5319901cf09fe Mon Sep 17 00:00:00 2001 From: "Paul B." Date: Tue, 12 Mar 2024 20:21:21 +0100 Subject: [PATCH 3/3] tests: add example overlay targetting objects with field condition This commit adds a case where the overlay is targetting objects with a condition on the object (here objects where `x-beta: true` is set). --- examples/valid/openapi.v3.json | 4 ++++ examples/valid/overlay.yaml | 4 ++++ test/commands/overlay.test.ts | 4 ++++ 3 files changed, 12 insertions(+) diff --git a/examples/valid/openapi.v3.json b/examples/valid/openapi.v3.json index 1d7f531c..eb45d496 100644 --- a/examples/valid/openapi.v3.json +++ b/examples/valid/openapi.v3.json @@ -124,6 +124,10 @@ "description": "Sentence about ping and pong", "example": "And that's how ping-pong ball is bumped", "type": "string" + }, + "ping": { + "type": "string", + "x-beta": true } } }, diff --git a/examples/valid/overlay.yaml b/examples/valid/overlay.yaml index a4ff582a..ec651e4f 100644 --- a/examples/valid/overlay.yaml +++ b/examples/valid/overlay.yaml @@ -25,6 +25,10 @@ actions: description: Remove all other servers so we can add our own. remove: true + - target: '$..[?(@["x-beta"]==true)]' + description: Remove all beta operations + remove: true + - target: '$.servers' description: Pop our server into the empty server array. update: diff --git a/test/commands/overlay.test.ts b/test/commands/overlay.test.ts index 74862024..941f759a 100644 --- a/test/commands/overlay.test.ts +++ b/test/commands/overlay.test.ts @@ -28,6 +28,10 @@ describe('overlay subcommand', () => { // Target on all servers expect(overlayedDefinition.servers.length).to.equal(1); expect(overlayedDefinition.servers[0].description).to.equal('Production'); + // Target on nodes which have "x-beta":true field + expect(overlayedDefinition.components.schemas.Pong.properties).to.have.all.keys( + 'pong', + ); }); test