diff --git a/package-lock.json b/package-lock.json index 44661377..9b5b465e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1591,6 +1591,51 @@ "scroll-into-view-if-needed": "2.2.29" }, "dependencies": { + "@gravity-ui/uikit": { + "version": "3.20.1", + "resolved": "https://registry.npmjs.org/@gravity-ui/uikit/-/uikit-3.20.1.tgz", + "integrity": "sha512-ojy03V+xIYVSikrTIhlxMJNF4F7CkTNEsTU11TBdyOq4/iyUqALxt+bvHhPy1nn4+GOr1NAXayJRxeY7+GEQqw==", + "requires": { + "@gravity-ui/i18n": "^1.0.0", + "@popperjs/core": "2.11.2", + "bem-cn-lite": "4.0.0", + "focus-trap": "6.7.2", + "lodash": "4.17.21", + "react-copy-to-clipboard": "5.1.0", + "react-popper": "2.2.5", + "react-sortable-hoc": "2.0.0", + "react-transition-group": "^4.4.5", + "react-virtualized-auto-sizer": "1.0.6", + "react-window": "1.8.6", + "resize-observer-polyfill": "1.5.1", + "tslib": "2.3.1", + "utility-types": "3.10.0" + }, + "dependencies": { + "@popperjs/core": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.2.tgz", + "integrity": "sha512-92FRmppjjqz29VMJ2dn+xdyXZBrMlE42AV6Kq6BwjWV7CNUW1hs2FtxSNLQE+gJhaZ6AAmYuO9y8dshhcBl7vA==" + }, + "bem-cn-lite": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/bem-cn-lite/-/bem-cn-lite-4.0.0.tgz", + "integrity": "sha512-ylyWbX63PqhJvm9xGcLAoiKYi87T5g4r5g6sx0dZHcvCtgYvnOWvVTZOQp+uB2DF8ZXsejnPkySvzKnsQhuOAg==", + "requires": { + "bem-cn": "^3.0.1" + } + }, + "react-popper": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/react-popper/-/react-popper-2.2.5.tgz", + "integrity": "sha512-kxGkS80eQGtLl18+uig1UIf9MKixFSyPxglsgLBxlYnyDf65BiY9B3nZSc6C9XUNDgStROB0fMQlTEz1KxGddw==", + "requires": { + "react-fast-compare": "^3.0.1", + "warning": "^4.0.2" + } + } + } + }, "bem-cn-lite": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bem-cn-lite/-/bem-cn-lite-4.1.0.tgz", @@ -1598,6 +1643,38 @@ "requires": { "bem-cn": "^3.0.1" } + }, + "focus-trap": { + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-6.7.2.tgz", + "integrity": "sha512-mRVv9QPCXITaDreu+pNXiPk1Rpn0WQtGvGrDo3Z/s2kdwtzFw/WOPfbLkdxWWvcahoInm9eRztuQOr1RNyQGrw==", + "requires": { + "tabbable": "^5.2.1" + } + }, + "react-virtualized-auto-sizer": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.6.tgz", + "integrity": "sha512-7tQ0BmZqfVF6YYEWcIGuoR3OdYe8I/ZFbNclFlGOC3pMqunkYF/oL30NCjSGl9sMEb17AnzixDz98Kqc3N76HQ==" + }, + "react-window": { + "version": "1.8.6", + "resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.6.tgz", + "integrity": "sha512-8VwEEYyjz6DCnGBsd+MgkD0KJ2/OXFULyDtorIiTz+QzwoP94tBoA7CnbtyXMm+cCeAUER5KJcPtWl9cpKbOBg==", + "requires": { + "@babel/runtime": "^7.0.0", + "memoize-one": ">=3.1.1 <6" + } + }, + "tabbable": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-5.3.3.tgz", + "integrity": "sha512-QD9qKY3StfbZqWOPLp0++pOrAVb/HbUi5xCc8cUo4XjP19808oaMiDzn0leBY5mCespIBM0CIZePzZjgzR83kA==" + }, + "tslib": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", + "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==" } } }, @@ -1704,30 +1781,26 @@ "integrity": "sha512-KNYNhQjA9XqLo0RVEwNRqdA7/Lx5LLrNDtqWCvOGzXTwKU0GFNlWJaoSvk7u97apag23nTxgmpk551FlRCfehA==" }, "@gravity-ui/uikit": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@gravity-ui/uikit/-/uikit-3.4.0.tgz", - "integrity": "sha512-2oImgNx+HO4hZrTL8LFY84r+9Oh+IBBacGAHeCRzjrzcGmMAlOPQWZe0QBKnvglodzdc8SJRgxppyQMbVxYWAw==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@gravity-ui/uikit/-/uikit-4.0.4.tgz", + "integrity": "sha512-9I+vrsUg/8dGGSevxF1h/FVuPkZDcCy5qc1OdzpyYI1h5cZo8NrHmS5oDH0+eetr7Hpei5e9b1nfnsxs79RhsA==", "requires": { "@gravity-ui/i18n": "^1.0.0", - "@popperjs/core": "2.11.2", + "@popperjs/core": "2.11.6", "bem-cn-lite": "4.0.0", - "focus-trap": "6.7.2", + "focus-trap": "7.2.0", "lodash": "4.17.21", - "react-copy-to-clipboard": "5.0.4", - "react-popper": "2.2.5", + "react-copy-to-clipboard": "5.1.0", + "react-popper": "2.3.0", "react-sortable-hoc": "2.0.0", - "react-virtualized-auto-sizer": "1.0.6", - "react-window": "1.8.6", + "react-transition-group": "^4.4.5", + "react-virtualized-auto-sizer": "1.0.7", + "react-window": "1.8.8", "resize-observer-polyfill": "1.5.1", "tslib": "2.3.1", "utility-types": "3.10.0" }, "dependencies": { - "@popperjs/core": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.2.tgz", - "integrity": "sha512-92FRmppjjqz29VMJ2dn+xdyXZBrMlE42AV6Kq6BwjWV7CNUW1hs2FtxSNLQE+gJhaZ6AAmYuO9y8dshhcBl7vA==" - }, "bem-cn-lite": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/bem-cn-lite/-/bem-cn-lite-4.0.0.tgz", @@ -1736,15 +1809,6 @@ "bem-cn": "^3.0.1" } }, - "react-popper": { - "version": "2.2.5", - "resolved": "https://registry.npmjs.org/react-popper/-/react-popper-2.2.5.tgz", - "integrity": "sha512-kxGkS80eQGtLl18+uig1UIf9MKixFSyPxglsgLBxlYnyDf65BiY9B3nZSc6C9XUNDgStROB0fMQlTEz1KxGddw==", - "requires": { - "react-fast-compare": "^3.0.1", - "warning": "^4.0.2" - } - }, "tslib": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", @@ -2139,6 +2203,12 @@ "integrity": "sha512-t2szdkwmg2JJyuCM20e8kR2X59WCE5Zkl4bzm1u1Oukjm79zpbiAv+QjnwLnuuV0WHEcX2NgUItu0pAMKuOPww==", "dev": true }, + "@types/html-escaper": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/html-escaper/-/html-escaper-3.0.0.tgz", + "integrity": "sha512-OcJcvP3Yk8mjYwf/IdXZtTE1tb/u0WF0qa29ER07ZHCYUBZXSN29Z1mBS+/96+kNMGTFUAbSz9X+pHmHpZrTCw==", + "dev": true + }, "@types/istanbul-lib-coverage": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz", @@ -3115,7 +3185,7 @@ "bem-cn-lite": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/bem-cn-lite/-/bem-cn-lite-3.0.0.tgz", - "integrity": "sha1-d/Crb2mnDaEFjYzfl87ouZ8/OZs=", + "integrity": "sha512-j22tbOj/8I0mxl1UvNToxLzqUwvbPkznn0G/Vtn4p6pEGoGhe+HSob84St9SCNP9OZnhuwrMMLM6v4hnH1zRvA==", "dev": true, "requires": { "bem-cn": "2.1.x" @@ -3124,7 +3194,7 @@ "bem-cn": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/bem-cn/-/bem-cn-2.1.3.tgz", - "integrity": "sha1-HiFFvA8RBghd5VY7ZhHHlkTnHCw=", + "integrity": "sha512-rhBVXe2eGqZq5d3xzItCP2m+jKA9m3kJvtbknN7E7FNOJx14GnvZnum0k24hN6VTCimGNHA13plMIsTER+pVLg==", "dev": true } } @@ -3874,9 +3944,9 @@ "dev": true }, "copy-to-clipboard": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.3.2.tgz", - "integrity": "sha512-Vme1Z6RUDzrb6xAI7EZlVZ5uvOk2F//GaxKUxajDqm9LhOVM1inxNAD2vy+UZDYsd0uyA9s7b3/FVZPSxqrCfg==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz", + "integrity": "sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==", "requires": { "toggle-selection": "^1.0.6" } @@ -4138,8 +4208,7 @@ "csstype": { "version": "3.0.10", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.10.tgz", - "integrity": "sha512-2u44ZG2OcNUO9HDp/Jl8C07x6pU/eTR3ncV91SiK3dhG9TWvRVsCoJw14Ckx5DgWkzGA3waZWO3d7pgqpUI/XA==", - "dev": true + "integrity": "sha512-2u44ZG2OcNUO9HDp/Jl8C07x6pU/eTR3ncV91SiK3dhG9TWvRVsCoJw14Ckx5DgWkzGA3waZWO3d7pgqpUI/XA==" }, "currently-unhandled": { "version": "0.4.1", @@ -4323,6 +4392,15 @@ "esutils": "^2.0.2" } }, + "dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "requires": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, "dom-serializer": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.2.2.tgz", @@ -5402,11 +5480,11 @@ } }, "focus-trap": { - "version": "6.7.2", - "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-6.7.2.tgz", - "integrity": "sha512-mRVv9QPCXITaDreu+pNXiPk1Rpn0WQtGvGrDo3Z/s2kdwtzFw/WOPfbLkdxWWvcahoInm9eRztuQOr1RNyQGrw==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.2.0.tgz", + "integrity": "sha512-v4wY6HDDYvzkBy4735kW5BUEuw6Yz9ABqMYLuTNbzAFPcBOGiGHwwcNVMvUz4G0kgSYh13wa/7TG3XwTeT4O/A==", "requires": { - "tabbable": "^5.2.1" + "tabbable": "^6.0.1" } }, "follow-redirects": { @@ -6006,9 +6084,9 @@ "integrity": "sha512-2zuLt85Ta+gIyvs4N88pCYskNrxf1TFv3LR9t5mdAZIX8BcgQQ48F2opUptvHa6m8zsy5v/a0i9mWzTrlNWU0Q==" }, "html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==" + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", + "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==" }, "html-parse-stringify": { "version": "3.0.1", @@ -6761,7 +6839,7 @@ "json-stringify-safe": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==" + "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=" }, "json5": { "version": "2.2.0", @@ -8922,12 +9000,12 @@ } }, "react-copy-to-clipboard": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/react-copy-to-clipboard/-/react-copy-to-clipboard-5.0.4.tgz", - "integrity": "sha512-IeVAiNVKjSPeGax/Gmkqfa/+PuMTBhutEvFUaMQLwE2tS0EXrAdgOpWDX26bWTXF3HrioorR7lr08NqeYUWQCQ==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/react-copy-to-clipboard/-/react-copy-to-clipboard-5.1.0.tgz", + "integrity": "sha512-k61RsNgAayIJNoy9yDsYzDe/yAZAzEbEgcz3DZMhF686LEyukcE1hzurxe85JandPUG+yTfGVFzuEw3xt8WP/A==", "requires": { - "copy-to-clipboard": "^3", - "prop-types": "^15.5.8" + "copy-to-clipboard": "^3.3.1", + "prop-types": "^15.8.1" } }, "react-dom": { @@ -8962,6 +9040,13 @@ "@babel/runtime": "^7.14.5", "html-escaper": "^2.0.2", "html-parse-stringify": "^3.0.1" + }, + "dependencies": { + "html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==" + } } }, "react-is": { @@ -9013,15 +9098,26 @@ "react-svg-core": "^3.0.3" } }, + "react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "requires": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + } + }, "react-virtualized-auto-sizer": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.6.tgz", - "integrity": "sha512-7tQ0BmZqfVF6YYEWcIGuoR3OdYe8I/ZFbNclFlGOC3pMqunkYF/oL30NCjSGl9sMEb17AnzixDz98Kqc3N76HQ==" + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.7.tgz", + "integrity": "sha512-Mxi6lwOmjwIjC1X4gABXMJcKHsOo0xWl3E3ugOgufB8GJU+MqrtY35aBuvCYv/razQ1Vbp7h1gWJjGjoNN5pmA==" }, "react-window": { - "version": "1.8.6", - "resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.6.tgz", - "integrity": "sha512-8VwEEYyjz6DCnGBsd+MgkD0KJ2/OXFULyDtorIiTz+QzwoP94tBoA7CnbtyXMm+cCeAUER5KJcPtWl9cpKbOBg==", + "version": "1.8.8", + "resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.8.tgz", + "integrity": "sha512-D4IiBeRtGXziZ1n0XklnFGu7h9gU684zepqyKzgPNzrsrk7xOCxni+TCckjg2Nr/DiaEEGVVmnhYSlT2rB47dQ==", "requires": { "@babel/runtime": "^7.0.0", "memoize-one": ">=3.1.1 <6" @@ -10601,9 +10697,9 @@ } }, "tabbable": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-5.3.3.tgz", - "integrity": "sha512-QD9qKY3StfbZqWOPLp0++pOrAVb/HbUi5xCc8cUo4XjP19808oaMiDzn0leBY5mCespIBM0CIZePzZjgzR83kA==" + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.0.1.tgz", + "integrity": "sha512-SYJSIgeyXW7EuX1ytdneO5e8jip42oHWg9xl/o3oTYhmXusZVgiA+VlPvjIN+kHii9v90AmzTZEBcsEvuAY+TA==" }, "table": { "version": "5.4.6", diff --git a/package.json b/package.json index 837604e5..eda607aa 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@doc-tools/components": "^2.4.0", "@doc-tools/transform": "^2.14.2", "@doc-tools/yfm2xliff": "0.0.5", + "@gravity-ui/uikit": "^4.0.4", "@octokit/core": "3.5.1", "@types/glob": "^8.0.0", "@types/json-stringify-safe": "^5.0.0", @@ -44,6 +45,7 @@ "fast-xml-parser": "^4.0.11", "glob": "^8.0.3", "highlight.js": "^11.7.0", + "html-escaper": "^3.0.3", "http-status-codes": "^2.2.0", "js-yaml": "4.1.0", "json-stringify-safe": "^5.0.1", @@ -71,6 +73,7 @@ "@babel/runtime": "7.16.7", "@types/async": "^3.2.15", "@types/chalk": "2.2.0", + "@types/html-escaper": "^3.0.0", "@types/js-yaml": "4.0.5", "@types/lodash": "4.14.178", "@types/markdown-it": "10.0.3", @@ -85,7 +88,7 @@ "@typescript-eslint/parser": "2.34.0", "babel-eslint": "10.1.0", "babel-loader": "8.2.3", - "bem-cn-lite": "3.0.0", + "bem-cn-lite": "^3.0.0", "css-loader": "3.6.0", "eslint": "6.8.0", "eslint-plugin-react": "7.28.0", @@ -101,11 +104,11 @@ "sass-loader": "8.0.2", "style-loader": "1.3.0", "ts-jest": "27.1.2", + "ts-node": "^10.9.1", "typescript": "3.9.10", "webpack": "4.46.0", "webpack-bundle-analyzer": "^4.6.1", - "webpack-cli": "3.3.12", - "ts-node": "^10.9.1" + "webpack-cli": "3.3.12" }, "husky": { "hooks": { diff --git a/src/app/components/App/App.tsx b/src/app/components/App/App.tsx index 01198f7c..e77acf6c 100644 --- a/src/app/components/App/App.tsx +++ b/src/app/components/App/App.tsx @@ -14,6 +14,8 @@ import {getDocSettings, withSavingSetting, updateRootClassName} from '../../util import '../../interceptors/leading-page-links'; +import {Runtime as OpenapiSandbox} from '../../../services/includers/batteries/openapi/plugin/public'; + import '@doc-tools/components/styles/themes.scss'; import '@doc-tools/components/styles/default.scss'; import '@doc-tools/components/styles/typography.scss'; @@ -82,6 +84,7 @@ export function App(props: DocInnerProps): ReactElement { ? : } + ); } diff --git a/src/constants.ts b/src/constants.ts index 1af32a48..e3b87119 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -20,6 +20,8 @@ const term = require('@doc-tools/transform/lib/plugins/term'); includes.collect = require('@doc-tools/transform/lib/plugins/includes/collect'); images.collect = require('@doc-tools/transform/lib/plugins/images/collect'); +import openapiSandbox from './services/includers/batteries/openapi/plugin'; + export const BUILD_FOLDER = 'build'; export const BUNDLE_FOLDER = '_bundle'; export const BUNDLE_FILENAME = 'app.js'; @@ -81,6 +83,7 @@ export const YFM_PLUGINS = [ monospace, table, term, + openapiSandbox, ]; export const PROCESSING_FINISHED = 'Processing finished:'; diff --git a/src/services/includers/batteries/openapi/constants.ts b/src/services/includers/batteries/openapi/constants.ts index 5773e93d..d1ab6433 100644 --- a/src/services/includers/batteries/openapi/constants.ts +++ b/src/services/includers/batteries/openapi/constants.ts @@ -1,6 +1,8 @@ const EOL = '\n'; const TAG_NAMES_FIELD = 'x-navtitle'; const BLOCK = EOL.repeat(2); +const INFO_TAB_NAME = 'Info'; +const SANDBOX_TAB_NAME = 'Sandbox'; const CONTACTS_SECTION_NAME = 'Contacts'; const TAGS_SECTION_NAME = 'Sections'; const ENDPOINTS_SECTION_NAME = 'Endpoints'; @@ -21,6 +23,8 @@ const SPEC_SECTION_TYPE = 'Open API'; export { TAG_NAMES_FIELD, BLOCK, + INFO_TAB_NAME, + SANDBOX_TAB_NAME, CONTACTS_SECTION_NAME, TAGS_SECTION_NAME, ENDPOINTS_SECTION_NAME, @@ -43,6 +47,8 @@ export { export default { TAG_NAMES_FIELD, BLOCK, + INFO_TAB_NAME, + SANDBOX_TAB_NAME, CONTACTS_SECTION_NAME, TAGS_SECTION_NAME, ENDPOINTS_SECTION_NAME, diff --git a/src/services/includers/batteries/openapi/generators/common.ts b/src/services/includers/batteries/openapi/generators/common.ts index 583bc060..01e2cd6c 100644 --- a/src/services/includers/batteries/openapi/generators/common.ts +++ b/src/services/includers/batteries/openapi/generators/common.ts @@ -67,6 +67,17 @@ function page(content: string) { return `${content}\n${HTML_COMMENTS_OPEN_DIRECTIVE} ${DISABLE_LINTER_DIRECTIVE} ${HTML_COMMENTS_CLOSE_DIRECTIVE}`; } -export {list, link, title, body, mono, bold, table, code, cut, block, page}; +function tabs(tabsObj: Record) { + return block([ + '{% list tabs %}', + Object.entries(tabsObj).map(([title, value]) => `- ${title} + + ${value.replace(/\n/g, '\n ')} + `).join('\n\n'), + '{% endlist %}\n', + ]); +} + +export {list, link, title, body, mono, bold, table, code, cut, block, page, tabs}; -export default {list, link, title, body, mono, bold, table, code, cut, block}; +export default {list, link, title, body, mono, bold, table, code, cut, block, tabs}; diff --git a/src/services/includers/batteries/openapi/generators/endpoint.ts b/src/services/includers/batteries/openapi/generators/endpoint.ts index a739cbf8..f7641a9b 100644 --- a/src/services/includers/batteries/openapi/generators/endpoint.ts +++ b/src/services/includers/batteries/openapi/generators/endpoint.ts @@ -1,5 +1,7 @@ -import {page, block, title, body, table, code, cut} from './common'; +import {page, block, title, body, table, code, cut, tabs} from './common'; import { + INFO_TAB_NAME, + SANDBOX_TAB_NAME, COOKIES_SECTION_NAME, HEADERS_SECTION_NAME, PATH_PARAMETERS_SECTION_NAME, @@ -15,31 +17,89 @@ import { Responses, Response, Schema, - Method, Refs, Server, - Servers, + Security, } from '../types'; import stringify from 'json-stringify-safe'; import {prepareTableRowData, prepareSampleObject, tableFromSchema, tableParameterName} from './traverse'; import {concatNewLine} from '../../common'; -function endpoint(allRefs: Refs, data: Endpoint) { +function endpoint(allRefs: Refs, data: Endpoint, sandboxPlugin: {host?: string} | undefined) { // try to remember, which tables we are already printed on page const pagePrintedRefs = new Set(); - const endpointPage = [ + + const contentWrapper = (content: string) => { + return sandboxPlugin ? tabs({ + [INFO_TAB_NAME]: content, + [SANDBOX_TAB_NAME]: sandbox({ + params: data.parameters, + host: sandboxPlugin?.host, + path: data.path, + security: data.security, + requestBody: data.requestBody, + method: data.method, + }), + }) : content; + }; + + const endpointPage = block([ title(1)(data.summary ?? data.id), - data.description?.length && body(data.description), - request(data.path, data.method, data.servers), - parameters(data.parameters), - openapiBody(allRefs, pagePrintedRefs, data.requestBody), - responses(allRefs, pagePrintedRefs, data.responses), - ]; + contentWrapper(block([ + data.description?.length && body(data.description), + request(data), + parameters(data.parameters), + openapiBody(allRefs, pagePrintedRefs, data.requestBody), + responses(allRefs, pagePrintedRefs, data.responses), + ])), + ]); + + return page(endpointPage); +} - return page(block(endpointPage)); +function sandbox({ + params, + host, + path, + security, + requestBody, + method, +}: { + params?: Parameters; + host?: string; + path: string; + security: Security[]; + requestBody?: any; + method: string; +}) { + const pathParams = params?.filter((param: Parameter) => param.in === 'path'); + const searchParams = params?.filter((param: Parameter) => param.in === 'query'); + const headers = params?.filter((param: Parameter) => param.in === 'header'); + let bodyStr: null | string = null; + if (requestBody?.type === 'application/json') { + bodyStr = JSON.stringify(prepareSampleObject(requestBody?.schema ?? {}), null, 2); + } + + const props = JSON.stringify({ + pathParams, + searchParams, + headers, + body: bodyStr, + method, + security, + path: path, + host: host ?? '', + }); + + return block([ + '{% openapi sandbox %}', + props, + '{% end openapi sandbox %}', + ]); } -function request(path: string, method: Method, servers: Servers) { +function request(data: Endpoint) { + const {path, method, servers} = data; const requestTableCols = ['method', 'url']; const hrefs = block(servers.map(({url}) => code(url + '/' + path))); @@ -59,7 +119,10 @@ function request(path: string, method: Method, servers: Servers) { requestTableRow, ]); - return block([title(2)(REQUEST_SECTION_NAME), requestTable]); + return block([ + title(2)(REQUEST_SECTION_NAME), + requestTable, + ]); } function parameters(params?: Parameters) { @@ -109,7 +172,7 @@ function openapiBody(allRefs: Refs, pagePrintedRefs: Set, obj?: Schema) const result = [ block([ - title(3)('Body'), + title(4)('Body'), cut(code(stringify(parsedSchema, null, 4)), type), content, ]), diff --git a/src/services/includers/batteries/openapi/generators/traverse.ts b/src/services/includers/batteries/openapi/generators/traverse.ts index 828868b2..9a1042fb 100644 --- a/src/services/includers/batteries/openapi/generators/traverse.ts +++ b/src/services/includers/batteries/openapi/generators/traverse.ts @@ -1,5 +1,5 @@ import {Refs} from '../types'; -import {JSONSchema6, JSONSchema6Definition} from 'json-schema'; +import {JSONSchema6} from 'json-schema'; import {table} from './common'; import slugify from 'slugify'; import {concatNewLine} from '../../common'; @@ -107,10 +107,15 @@ function findRef(allRefs: Refs, value: JSONSchema6): string | undefined { } return undefined; } +type OpenJSONSchema = JSONSchema6 & {example?: any}; +type OpenJSONSchemaDefinition = OpenJSONSchema | boolean; // sample key-value JSON body -export function prepareSampleObject(schema: JSONSchema6, callstack: JSONSchema6[] = []) { +export function prepareSampleObject(schema: OpenJSONSchema, callstack: JSONSchema6[] = []) { const result: { [key: string]: any } = {}; + if (schema.example) { + return schema.example; + } const merged = merge(schema); Object.entries(merged.properties || {}).forEach(([key, value]) => { const required = isRequired(key, merged); @@ -122,8 +127,11 @@ export function prepareSampleObject(schema: JSONSchema6, callstack: JSONSchema6[ return result; } -function prepareSampleElement(key: string, v: JSONSchema6Definition, required: boolean, callstack: JSONSchema6[]): any { +function prepareSampleElement(key: string, v: OpenJSONSchemaDefinition, required: boolean, callstack: JSONSchema6[]): any { const value = merge(v); + if (value.example) { + return value.example; + } if (value.enum?.length) { return value.enum[0]; } @@ -172,7 +180,7 @@ function prepareSampleElement(key: string, v: JSONSchema6Definition, required: b // - $ref: '#/components/schemas/TimeInterval1' // description: asfsdfsdf // type: object -function merge(value: JSONSchema6Definition): JSONSchema6 { +function merge(value: OpenJSONSchemaDefinition): OpenJSONSchema { if (typeof value === 'boolean') { throw Error('Boolean value isn\'t supported'); } diff --git a/src/services/includers/batteries/openapi/index.ts b/src/services/includers/batteries/openapi/index.ts index 173ad02f..3f19c4f0 100644 --- a/src/services/includers/batteries/openapi/index.ts +++ b/src/services/includers/batteries/openapi/index.ts @@ -31,7 +31,7 @@ class OpenApiIncluderError extends Error { } async function includerFunction(params: IncluderFunctionParams) { - const {readBasePath, writeBasePath, tocPath, passedParams: {input, leadingPage}, index} = params; + const {readBasePath, writeBasePath, tocPath, passedParams: {input, leadingPage, sandbox}, index} = params; const tocDirPath = dirname(tocPath); @@ -70,7 +70,7 @@ async function includerFunction(params: IncluderFunctionParams) { try { await mkdir(writePath, {recursive: true}); await generateToc({data, writePath, leadingPageName}); - await generateContent({data, allRefs, writePath, leadingPageSpecRenderMode}); + await generateContent({data, allRefs, writePath, leadingPageSpecRenderMode, sandbox}); } catch (err) { if (err instanceof Error) { throw new OpenApiIncluderError(err.toString(), tocPath); @@ -136,10 +136,13 @@ export type generateContentParams = { writePath: string; allRefs: Refs; leadingPageSpecRenderMode: LeadingPageSpecRenderMode; + sandbox?: { + host?: string; + }; }; async function generateContent(params: generateContentParams): Promise { - const {data, writePath, allRefs, leadingPageSpecRenderMode} = params; + const {data, writePath, allRefs, leadingPageSpecRenderMode, sandbox} = params; const results = []; @@ -163,12 +166,12 @@ async function generateContent(params: generateContentParams): Promise { if (!endpoints) { return; } endpoints.forEach((endpoint) => { - results.push(handleEndpointIncluder(allRefs, endpoint, join(writePath, id))); + results.push(handleEndpointIncluder(allRefs, endpoint, join(writePath, id), sandbox)); }); }); for (const endpoint of spec.endpoints) { - results.push(handleEndpointIncluder(allRefs, endpoint, join(writePath))); + results.push(handleEndpointIncluder(allRefs, endpoint, join(writePath), sandbox)); } for (const {path, content} of results) { @@ -177,9 +180,9 @@ async function generateContent(params: generateContentParams): Promise { } } -function handleEndpointIncluder(allRefs: Refs, endpoint: Endpoint, pathPrefix: string) { +function handleEndpointIncluder(allRefs: Refs, endpoint: Endpoint, pathPrefix: string, sandbox: {host?: string} | undefined) { const path = join(pathPrefix, mdPath(endpoint)); - const content = generators.endpoint(allRefs, endpoint); + const content = generators.endpoint(allRefs, endpoint, sandbox); return {path, content}; } diff --git a/src/services/includers/batteries/openapi/parsers.ts b/src/services/includers/batteries/openapi/parsers.ts index a6d95588..66f28f05 100644 --- a/src/services/includers/batteries/openapi/parsers.ts +++ b/src/services/includers/batteries/openapi/parsers.ts @@ -108,10 +108,17 @@ function tags(spec: OpenapiSpec): Map { function paths(spec: OpenapiSpec, tagsByID: Map): Specification { const endpoints: Endpoints = []; - const {paths, servers} = spec; - + const {paths, servers, components = {}, security: globalSecurity = []} = spec; const visiter = ({path, method, endpoint}: VisiterParams) => { - const {summary, description, tags = [], operationId, parameters, responses, requestBody} = endpoint; + const {summary, description, tags = [], operationId, parameters, responses, requestBody, security = []} = endpoint; + const parsedSecurity = [...security, ...globalSecurity].reduce((arr, item) => { + arr.push(...Object.keys(item).reduce((acc, key) => { + // @ts-ignore + acc.push(components.securitySchemes[key]); + return acc; + }, [])); + return arr; + }, []); const opid = (path: string, method: string, id?: string) => slugify(id ?? ([path, method].join('-'))); @@ -163,6 +170,7 @@ function paths(spec: OpenapiSpec, tagsByID: Map): Specification { type: contentType, schema: requestBody.content[contentType].schema, } : undefined, + security: parsedSecurity, }; for (const tag of tags) { diff --git a/src/services/includers/batteries/openapi/plugin/constants.ts b/src/services/includers/batteries/openapi/plugin/constants.ts new file mode 100644 index 00000000..c087e546 --- /dev/null +++ b/src/services/includers/batteries/openapi/plugin/constants.ts @@ -0,0 +1,22 @@ +import block from 'bem-cn-lite'; + +export const Text = { + BUTTON_SUBMIT: 'Send', + + HEADER_PARAMS_SECTION_TITLE: 'Header params', + QUERY_PARAMS_SECTION_TITLE: 'Query params', + PATH_PARAMS_SECTION_TITLE: 'Path params', + + BODY_INPUT_LABEL: 'Body', + + RESPONSE_ERROR_SECTION_TITLE: 'Response error', + RESPONSE_SECTION_TITLE: 'Response', + + URL_VALUE_LABEL: 'Request URL', + RESPONSE_STATUS_LABEL: 'Status', + RESPONSE_BODY_LABEL: 'Body', + + RESPONSE_FILE_LABEL: 'File from response', +}; + +export const yfmSandbox = block('yfm-sandbox'); diff --git a/src/services/includers/batteries/openapi/plugin/index.ts b/src/services/includers/batteries/openapi/plugin/index.ts new file mode 100644 index 00000000..1b45ad03 --- /dev/null +++ b/src/services/includers/batteries/openapi/plugin/index.ts @@ -0,0 +1,92 @@ +import StateBlock from 'markdown-it/lib/rules_block/state_block'; +import Token from 'markdown-it/lib/token'; +import {MarkdownItPluginCb} from '@doc-tools/transform/lib/plugins/typings'; +import {escape} from 'html-escaper'; + +function parserOpenapiSandboxBlock(state: StateBlock, start: number, end: number, silent: boolean) { + let firstLine, lastLine, next, lastPos, found = false, + pos = state.bMarks[start] + state.tShift[start], + max = state.eMarks[start]; + + const startMark = '{% openapi sandbox %}'; + const endMark = '{% end openapi sandbox %}'; + if (pos + startMark.length > max) { + return false; + } + + if (state.src.slice(pos, pos + startMark.length) !== startMark) { + return false; + } + pos += startMark.length; + firstLine = state.src.slice(pos, max); + + + if (silent) { + return true; + } + if (firstLine.slice(-endMark.length) === endMark) { + firstLine = firstLine.slice(0, -endMark.length); + found = true; + } + + for (next = start; !found;) { + + next++; + + if (next >= end) { + break; + } + + pos = state.bMarks[next] + state.tShift[next]; + max = state.eMarks[next]; + + if (pos < max && state.tShift[next] < state.blkIndent) { + // non-empty line with negative indent should stop the list: + break; + } + + if (state.src.slice(pos, max).slice(-endMark.length) === endMark) { + lastPos = state.src.slice(0, max).lastIndexOf(endMark); + lastLine = state.src.slice(pos, lastPos); + found = true; + } + + } + + state.line = next + 1; + + const token = state.push('openapi_sandbox_block', 'openapi_sandbox', 0); + token.block = true; + token.content = (firstLine ? firstLine + '\n' : '') + + state.getLines(start + 1, next, state.tShift[start], true) + + (lastLine ? lastLine : ''); + token.map = [start, state.line]; + token.markup = startMark; + return true; +} + +const openapiSandboxPlugin: MarkdownItPluginCb = (md) => { + const openapiSandboxBlock = (jsonString: string) => { + try { + const props = escape(jsonString); + + return `
`; + } catch (error) { + console.log(error); + return jsonString; + } + }; + + const openapiSandboxRenderer = (tokens: Token[], idx: number) => { + return openapiSandboxBlock(tokens[idx].content); + }; + + try { + md.block.ruler.before('meta', 'openapi_sandbox_block', parserOpenapiSandboxBlock); + } catch (e) { + md.block.ruler.push('openapi_sandbox_block', parserOpenapiSandboxBlock); + } + md.renderer.rules.openapi_sandbox_block = openapiSandboxRenderer; +}; + +export default openapiSandboxPlugin; diff --git a/src/services/includers/batteries/openapi/plugin/public/.eslintrc b/src/services/includers/batteries/openapi/plugin/public/.eslintrc new file mode 100644 index 00000000..a1b2560e --- /dev/null +++ b/src/services/includers/batteries/openapi/plugin/public/.eslintrc @@ -0,0 +1,3 @@ +{ + "extends": ["../../../../../../app/.eslintrc"] +} diff --git a/src/services/includers/batteries/openapi/plugin/public/bem-cn-lite.d.ts b/src/services/includers/batteries/openapi/plugin/public/bem-cn-lite.d.ts new file mode 100644 index 00000000..642f17cd --- /dev/null +++ b/src/services/includers/batteries/openapi/plugin/public/bem-cn-lite.d.ts @@ -0,0 +1,17 @@ +declare module 'bem-cn-lite' { + interface Modifications { + [name: string]: string | boolean | number | undefined; + } + + interface Inner { + (elem: string, mods: Modifications | null, mixin?: string): string; + (elem: string, mixin?: string): string; + (elem: string, mods: Modifications): string; + (mods: Modifications | null, mixin?: string): string; + (elem: string): string; + (mods: Modifications); + (): string; + } + + export default function Outer(name: string): Inner; +} diff --git a/src/services/includers/batteries/openapi/plugin/public/components/Body.tsx b/src/services/includers/batteries/openapi/plugin/public/components/Body.tsx new file mode 100644 index 00000000..a4476136 --- /dev/null +++ b/src/services/includers/batteries/openapi/plugin/public/components/Body.tsx @@ -0,0 +1,72 @@ +import type {Field, Nullable} from '../types'; +import React from 'react'; +import {TextInput, Text} from '@gravity-ui/uikit'; + +import {Text as TextEnum} from '../../constants'; + +import {Column} from './Column'; + +type Props = { + value: Nullable; +}; + +type State = { + error: Nullable; + value: Nullable; +}; + +export class Body extends React.Component implements Field { + constructor(props: Props) { + super(props); + + this.state = { + error: null, + value: props.value, + }; + } + + render() { + const {error, value} = this.state; + + if (value === undefined || value === null) { + return null; + } + + const onChange = (newValue: string) => { + this.setState((prevState) => ({ + ...prevState, + value: newValue, + })); + }; + + return ( + + {TextEnum.BODY_INPUT_LABEL} + + + ); + } + + validate() { + const error = this.isRequired && !this.state.value ? 'Required' : undefined; + + this.setState({error}); + + return error; + } + + value() { + return this.state.value; + } + + private get isRequired() { + return this.props.value !== undefined && this.props.value !== null; + } +} diff --git a/src/services/includers/batteries/openapi/plugin/public/components/Column.tsx b/src/services/includers/batteries/openapi/plugin/public/components/Column.tsx new file mode 100644 index 00000000..b1bfc7f9 --- /dev/null +++ b/src/services/includers/batteries/openapi/plugin/public/components/Column.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import {yfmSandbox} from '../../constants'; + +export const Column: React.FC<{ + className?: string; + gap?: number; +}> = ({className, gap = 20, children}) => { + const style = { + gap: gap + 'px', + }; + + return ( +
+ {children} +
+ ); +}; diff --git a/src/services/includers/batteries/openapi/plugin/public/components/Error.tsx b/src/services/includers/batteries/openapi/plugin/public/components/Error.tsx new file mode 100644 index 00000000..3c9d4afe --- /dev/null +++ b/src/services/includers/batteries/openapi/plugin/public/components/Error.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import {Text, Card} from '@gravity-ui/uikit'; + +import {Text as TextEnum, yfmSandbox} from '../../constants'; +import {ErrorState} from '../types'; +import {Column} from './'; + +export const Error = ({message}: ErrorState) => { + return + {TextEnum.RESPONSE_ERROR_SECTION_TITLE} + + + {message} + + + ; +}; diff --git a/src/services/includers/batteries/openapi/plugin/public/components/Loader.tsx b/src/services/includers/batteries/openapi/plugin/public/components/Loader.tsx new file mode 100644 index 00000000..b6af7fee --- /dev/null +++ b/src/services/includers/batteries/openapi/plugin/public/components/Loader.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +import {Loader as LoaderBase} from '@gravity-ui/uikit'; +import {yfmSandbox} from '../../constants'; + +export const Loader = () => { + return
+ +
; +}; diff --git a/src/services/includers/batteries/openapi/plugin/public/components/Params.tsx b/src/services/includers/batteries/openapi/plugin/public/components/Params.tsx new file mode 100644 index 00000000..d02da06e --- /dev/null +++ b/src/services/includers/batteries/openapi/plugin/public/components/Params.tsx @@ -0,0 +1,106 @@ +import type {Field, Nullable} from '../types'; +import React from 'react'; +import {TextInput, Text} from '@gravity-ui/uikit'; + +import {Primitive, Parameter, Parameters} from '../../../types'; + +import {Column} from './Column'; +import {merge} from '../utils'; + +function validate( + params: Parameters | undefined, + values: Record>, +): Nullable> { + const errors = merge(params || [], (param) => ( + param.required && !values[param.name] + ? {[param.name]: 'Required'} + : undefined + )); + + return Object.keys(errors).length + ? errors + : undefined; +} + +export class Params extends React.Component<{ + title: string; + params?: Array; +}, { + values: Record>; + errors: Nullable>; +}> implements Field>, Record> { + private onchange: Record void>; + + constructor(props: { + title: string; + params?: Array; + }) { + super(props); + + this.state = { + errors: undefined, + values: merge(props.params || [], (param) => ({ + [param.name]: param.example ?? '', + })), + }; + + this.onchange = merge(props.params || [], (param) => ({ + [param.name]: this.createOnChange(param.name), + })); + } + + render() { + const {params, title} = this.props; + const {values, errors} = this.state; + + if (!params || !params.length) { + return null; + } + + return ( + + {title} + + {params.map((param, index) => ( + + + {param.name} + {Boolean(param.required) && *}: + + + + + ))} + + + ); + } + + validate() { + const errors = validate(this.props.params, this.state.values); + + this.setState({errors}); + + return errors; + } + + value() { + return this.state.values; + } + + private createOnChange = (paramName: string) => (value: string) => { + this.setState((prevState) => ({ + errors: undefined, + values: { + ...prevState.values, + [paramName]: value, + }, + })); + }; +} diff --git a/src/services/includers/batteries/openapi/plugin/public/components/Response.tsx b/src/services/includers/batteries/openapi/plugin/public/components/Response.tsx new file mode 100644 index 00000000..7ad698c8 --- /dev/null +++ b/src/services/includers/batteries/openapi/plugin/public/components/Response.tsx @@ -0,0 +1,63 @@ +import type {ResponseState} from '../types'; +import React, {useState, useEffect} from 'react'; +import {Text, Card} from '@gravity-ui/uikit'; + +import {Text as TextEnum, yfmSandbox} from '../../constants'; +import {Column} from './'; + +export const Response: React.FC<{ + response: ResponseState; +}> = ({response}) => { + const {url, status, file, text} = response; + + const [fileUrl, setFileUrl] = useState(null); + + useEffect(() => { + if (file) { + setFileUrl(window.URL.createObjectURL(file.blob)); + } + + return () => { + if (fileUrl) { + window.URL.revokeObjectURL(fileUrl); + } + }; + }, [file]); + + return + {TextEnum.RESPONSE_SECTION_TITLE} +
+ {TextEnum.RESPONSE_STATUS_LABEL}: + {status} +
+
+ {TextEnum.URL_VALUE_LABEL}: + {url} +
+ { + text !== undefined &&
+ {TextEnum.RESPONSE_BODY_LABEL}: + + +
{text}
+
+
+
+ } + { + file && fileUrl &&
+ {TextEnum.RESPONSE_FILE_LABEL}: + + + {file.name} + + +
+ } +
; +}; diff --git a/src/services/includers/batteries/openapi/plugin/public/components/Result.tsx b/src/services/includers/batteries/openapi/plugin/public/components/Result.tsx new file mode 100644 index 00000000..d8725aac --- /dev/null +++ b/src/services/includers/batteries/openapi/plugin/public/components/Result.tsx @@ -0,0 +1,81 @@ +import type {ResponseState, ErrorState} from '../types'; +import React, {useState, useEffect} from 'react'; +import {Loader} from './Loader'; +import {Response} from './Response'; +import {Error} from './Error'; + +export const getAttachName = (response: Response): string => { + const unknownName = 'unknown file'; + const disposition = response.headers.get('Content-Disposition'); + + if (disposition) { + const filenameRegex = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/; + const matches = filenameRegex.exec(disposition); + if (matches !== null && matches[1]) { + return matches[1].replace(/['"]/g, ''); + } + } + + return unknownName; +}; + +async function processResponse(response: Response): Promise { + const contentType = response.headers.get('Content-Type') || ''; + const contentDisposition = response.headers.get('Content-Disposition') || ''; + const isAttachment = contentDisposition.includes('attachment'); + + if (isAttachment) { + return { + status: response.status, + url: response.url, + file: { + blob: await response.blob(), + name: getAttachName(response), + }, + }; + } else { + let text: string; + + if (contentType.includes('json')) { + text = JSON.stringify(await response.json(), null, 2); + } else { + text = await response.text(); + } + + return { + status: response.status, + url: response.url, + text, + }; + } +} + +export const Result: React.FC<{ + request: Promise; +}> = ({request}) => { + const [response, setResponse] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + const scope = request; + const onResponse = (result: ResponseState) => scope === request ? setResponse(result) : null; + const onError = (result: ErrorState) => scope === request ? setError(result) : null; + + request + .then(processResponse) + .then(onResponse, onError); + + return () => { + setResponse(null); + setError(null); + }; + }, [request]); + + return ( + <> + { !response && !error && } + { response && } + { error && } + + ); +}; diff --git a/src/services/includers/batteries/openapi/plugin/public/components/index.ts b/src/services/includers/batteries/openapi/plugin/public/components/index.ts new file mode 100644 index 00000000..15bca0b7 --- /dev/null +++ b/src/services/includers/batteries/openapi/plugin/public/components/index.ts @@ -0,0 +1,7 @@ +export {Column} from './Column'; +export {Params} from './Params'; +export {Body} from './Body'; +export {Response} from './Response'; +export {Error} from './Error'; +export {Loader} from './Loader'; +export {Result} from './Result'; diff --git a/src/services/includers/batteries/openapi/plugin/public/index.tsx b/src/services/includers/batteries/openapi/plugin/public/index.tsx new file mode 100644 index 00000000..2fa46a39 --- /dev/null +++ b/src/services/includers/batteries/openapi/plugin/public/index.tsx @@ -0,0 +1,27 @@ +import React, {useEffect, useState} from 'react'; +import {createPortal} from 'react-dom'; +import {unescape} from 'html-escaper'; + +import {Sandbox} from './sandbox'; + +export const Runtime: React.FC = () => { + const [sandbox, setSandbox] = useState(null); + + useEffect(() => { + setSandbox(document.querySelector('.yfm-sandbox-js')); + }); + + if (!sandbox || !sandbox.dataset.props) { + return null; + } + + try { + const props = JSON.parse(unescape(sandbox.dataset.props)); + + return createPortal(, sandbox); + } catch (error) { + console.log(error); + + return null; + } +}; diff --git a/src/services/includers/batteries/openapi/plugin/public/sandbox.scss b/src/services/includers/batteries/openapi/plugin/public/sandbox.scss new file mode 100644 index 00000000..9d0cccfa --- /dev/null +++ b/src/services/includers/batteries/openapi/plugin/public/sandbox.scss @@ -0,0 +1,29 @@ +.yfm-sandbox { + &__column { + display: flex; + flex-direction: column; + } + + &__card-text { + padding: 16px; + display: block; + + text-align: initial; + } + + &__card { + overflow-x: auto; + } + + &__loader-container { + height: 300px; + + display: flex; + align-items: center; + justify-content: center; + } + + & &__pre { + margin: 0; + } +} diff --git a/src/services/includers/batteries/openapi/plugin/public/sandbox.tsx b/src/services/includers/batteries/openapi/plugin/public/sandbox.tsx new file mode 100644 index 00000000..bf7fac44 --- /dev/null +++ b/src/services/includers/batteries/openapi/plugin/public/sandbox.tsx @@ -0,0 +1,70 @@ +import type {FormState} from './types'; +import React, {useState, useRef} from 'react'; +import {Button} from '@gravity-ui/uikit'; +import {Column, Params, Body, Result} from './components'; + +import {SandboxProps} from '../../types'; +import {Text, yfmSandbox} from '../constants'; +import {prepareRequest, prepareHeaders, collectValues, collectErrors} from './utils'; + +import './sandbox.scss'; + +export const Sandbox: React.FC = (props) => { + const preparedHeaders = prepareHeaders(props); + const refs = { + path: useRef(null), + search: useRef(null), + headers: useRef(null), + body: useRef(null), + }; + const [request, setRequest] = useState | null>(null); + + const onSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + if (collectErrors(refs)) { + return; + } + + const values = collectValues(refs) as FormState; + const {url, headers, body} = prepareRequest((props.host ?? '') + '/' + props.path, values); + + setRequest(fetch(url, { + method: props.method, + headers, + ...body, + })); + }; + + return ( +
+ + + + + + {request && } +
+ +
+
+
+ ); +}; diff --git a/src/services/includers/batteries/openapi/plugin/public/types.ts b/src/services/includers/batteries/openapi/plugin/public/types.ts new file mode 100644 index 00000000..452b8e54 --- /dev/null +++ b/src/services/includers/batteries/openapi/plugin/public/types.ts @@ -0,0 +1,28 @@ +export interface Field { + validate(): Nullable; + + value(): Nullable; +} + +export type FormState = { + path: Record; + search: Record; + headers: Record; + body: string | undefined; +}; + +export type ResponseState = { + url: string; + status: number; + text?: string; + file?: { + blob: Blob; + name: string; + }; +}; + +export type ErrorState = { + message: string; +}; + +export type Nullable = T | null | undefined; diff --git a/src/services/includers/batteries/openapi/plugin/public/utils.ts b/src/services/includers/batteries/openapi/plugin/public/utils.ts new file mode 100644 index 00000000..0208984a --- /dev/null +++ b/src/services/includers/batteries/openapi/plugin/public/utils.ts @@ -0,0 +1,94 @@ +import type {RefObject} from 'react'; +import type {Parameters, Security} from '../../types'; +import type {Field, FormState} from './types'; + +export const merge = (items: T[], iterator: (item: T) => Record | undefined) => { + return (items).reduce( + (acc, item) => Object.assign(acc, iterator(item)), + {} as Record, + ); +}; + +export const prepareRequest = (urlTemplate: string, {search, headers, path, body}: FormState) => { + const requestUrl = Object.entries(path).reduce((acc, [key, value]) => { + return acc.replace(`{${key}}`, encodeURIComponent(value)); + }, urlTemplate); + + const searchParams = new URLSearchParams(); + Object.entries(search).forEach(([key, value]) => { + searchParams.append(key, value); + }); + + const searchString = searchParams.toString(); + const url = requestUrl + (searchString ? '?' + searchString : ''); + + return { + url, + headers: body ? {...headers, 'Content-Type': 'application/json'} : headers, + // TODO: match request types (www-form-url-encoded should be handled too) + body: body ? {body} : {}, + }; +}; + +export const prepareHeaders = ({headers, security}: { + security?: Security[]; + headers?: Parameters; +}) => { + const preparedHeaders = headers ? [...headers] : []; + + const hasOAuth2 = security?.find(({type}) => type === 'oauth2'); + if (hasOAuth2) { + preparedHeaders.push({ + name: 'Authorization', + schema: { + type: 'string', + }, + in: 'header', + required: true, + description: '', + example: 'Bearer ', + }); + } + + return preparedHeaders; +}; + +export function collectErrors(fields: Record>) { + const errors = Object.keys(fields).reduce((acc, key) => { + const field = fields[key].current; + + if (!field) { + return acc; + } + + const error = field.validate(); + + if (error) { + acc[key] = error; + } + + return acc; + }, {} as Record); + + if (!Object.keys(errors).length) { + return null; + } + + return errors; +} + +export function collectValues>>(fields: F): Record { + const values = Object.keys(fields).reduce((acc, key: keyof F) => { + const field = fields[key].current; + + if (!field) { + return acc; + } + + acc[key] = field.value(); + + return acc; + }, {} as Record); + + return values; +} diff --git a/src/services/includers/batteries/openapi/types.ts b/src/services/includers/batteries/openapi/types.ts index cd5af759..a6f7692b 100644 --- a/src/services/includers/batteries/openapi/types.ts +++ b/src/services/includers/batteries/openapi/types.ts @@ -6,8 +6,24 @@ export const titleDepths = [1, 2, 3, 4, 5, 6] as const; export type TitleDepth = typeof titleDepths[number]; -/* eslint-disable-next-line @typescript-eslint/no-explicit-any */ -export type OpenapiSpec = {[key: string]: any}; +export type SandboxProps = { + path: string; + host?: string; + method: Method; + pathParams?: Parameters; + searchParams?: Parameters; + headers?: Parameters; + body?: string; + security?: Security[]; +}; + +export type OpenapiSpec = { + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + [key: string]: any; + security?: Array>; +}; + +export type Security = {type: string; description: string}; export type OpenapiOperation = { summary?: string; @@ -18,6 +34,7 @@ export type OpenapiOperation = { parameters?: Parameters; responses?: {}; requestBody?: any; + security?: Array>; 'x-navtitle': string[]; }; @@ -65,6 +82,7 @@ export type Endpoint = { parameters?: Parameters; responses?: Responses; requestBody?: Schema; + security: Security[]; }; export type Specification = {