diff --git a/CHANGELOG.md b/CHANGELOG.md index c3f8e5a..3bfb02f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,17 @@ -# 1.3.1 +# 1.3.2 - 22 Aug 2025 +Feature: +- add `withHeader` for adding custom headers to response schema +- spread all possible path for optional params +- provider can be `null` to disable provider +- export `toOpenAPI` to generate spec programmatically + +Breaking change: +- rename `@elysiajs/swagger` to `@elysiajs/openapi` +- map all `swagger`, and `scalar` prefix to respective `swagger` and `scalar` properties +- rename `swaggerConfig`, and `scalarConfig` to `swagger` and `scalar` respectively +- map `excludePaths`, `excludeMethods`, `excludeTags`, `excludeStaticFiles` to property of `excludes` + +# 1.3.1 - 28 Jun 2025 Bug fix: - Using relative path for specPath diff --git a/README.md b/README.md index bb1a75d..ed22331 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,18 @@ -# @elysiajs/swagger -Plugin for [elysia](https://github.com/elysiajs/elysia) to auto-generate Swagger page. +# @elysiajs/openapi +Plugin for [elysia](https://github.com/elysiajs/elysia) to auto-generate API documentation page. ## Installation ```bash -bun add @elysiajs/swagger +bun add @elysiajs/openapi ``` ## Example ```typescript import { Elysia, t } from 'elysia' -import { swagger } from '@elysiajs/swagger' +import { openapi } from '@elysiajs/openapi' const app = new Elysia() - .use(swagger()) + .use(openapi()) .get('/', () => 'hi', { response: t.String({ description: 'sample description' }) }) .post( '/json/:id', @@ -43,34 +43,59 @@ const app = new Elysia() .listen(8080); ``` -Then go to `http://localhost:8080/swagger`. +Then go to `http://localhost:8080/openapi`. # config -## provider -@default 'scalar' -Choose between [Scalar](https://github.com/scalar/scalar) & [SwaggerUI](https://github.com/swagger-api/swagger-ui) +## enabled +@default true +Enable/Disable the plugin -## scalar -Customize scalarConfig, refers to [Scalar config](https://github.com/scalar/scalar/blob/main/documentation/configuration.md) +## documentation +OpenAPI documentation information -## swagger -Customize Swagger config, refers to [Swagger 3.0.3 config](https://swagger.io/specification/v3) +@see https://spec.openapis.org/oas/v3.0.3.html -## path -@default '/swagger' +## exclude +Configuration to exclude paths or methods from documentation -The endpoint to expose Swagger +## exclude.methods +List of methods to exclude from documentation -## scalarCDN -Self-host the scalar bundle or point to a different CDN. +## exclude.paths +List of paths to exclude from documentation -## excludeStaticFile +## exclude.staticFile @default true -Determine if Swagger should exclude static files. +Exclude static file routes from documentation -## exclude -@default [] +## exclude.tags +List of tags to exclude from documentation + +## path +@default '/openapi' + +The endpoint to expose OpenAPI documentation frontend -Paths to exclude from the Swagger endpoint +## provider +@default 'scalar' + +OpenAPI documentation frontend between: +- [Scalar](https://github.com/scalar/scalar) +- [SwaggerUI](https://github.com/openapi-api/openapi-ui) +- null: disable frontend + +## references +Additional OpenAPI reference for each endpoint + +## scalar +Scalar configuration, refers to [Scalar config](https://github.com/scalar/scalar/blob/main/documentation/configuration.md) + +## specPath +@default '/${path}/json' + +The endpoint to expose OpenAPI specification in JSON format + +## swagger +Swagger config, refers to [Swagger config](https://swagger.io/docs/open-source-tools/swagger-ui/usage/configuration/) diff --git a/build.ts b/build.ts index e1305dc..5628259 100644 --- a/build.ts +++ b/build.ts @@ -4,30 +4,30 @@ import { build, type Options } from 'tsup' await $`rm -rf dist` const tsupConfig: Options = { - entry: ['src/**/*.ts'], - splitting: false, - sourcemap: false, - clean: true, - bundle: true + entry: ['src/**/*.ts'], + splitting: false, + sourcemap: false, + clean: true, + bundle: true } satisfies Options await Promise.all([ - // ? tsup esm - build({ - outDir: 'dist', - format: 'esm', - target: 'node20', - cjsInterop: false, - ...tsupConfig - }), - // ? tsup cjs - build({ - outDir: 'dist/cjs', - format: 'cjs', - target: 'node20', - // dts: true, - ...tsupConfig - }) + // ? tsup esm + build({ + outDir: 'dist', + format: 'esm', + target: 'node20', + cjsInterop: false, + ...tsupConfig + }), + // ? tsup cjs + build({ + outDir: 'dist/cjs', + format: 'cjs', + target: 'node20', + // dts: true, + ...tsupConfig + }) ]) await $`tsc --project tsconfig.dts.json` diff --git a/bun.lock b/bun.lock index 95ca0d9..fd67186 100644 --- a/bun.lock +++ b/bun.lock @@ -4,18 +4,17 @@ "": { "name": "@elysiajs/swagger", "dependencies": { - "@scalar/themes": "^0.9.52", - "@scalar/types": "^0.0.12", + "@sinclair/typemap": "^0.10.1", "openapi-types": "^12.1.3", - "pathe": "^1.1.2", }, "devDependencies": { - "@apidevtools/swagger-parser": "^10.1.0", - "@types/bun": "1.1.14", - "elysia": "1.3.0-exp.71", + "@apidevtools/swagger-parser": "^12.0.0", + "@scalar/types": "^0.2.13", + "@types/bun": "1.2.20", + "elysia": "1.3.21", "eslint": "9.6.0", - "tsup": "^8.1.0", - "typescript": "^5.5.3", + "tsup": "^8.5.0", + "typescript": "^5.9.2", }, "peerDependencies": { "elysia": ">= 1.3.0", @@ -23,13 +22,15 @@ }, }, "packages": { - "@apidevtools/json-schema-ref-parser": ["@apidevtools/json-schema-ref-parser@11.7.2", "", { "dependencies": { "@jsdevtools/ono": "^7.1.3", "@types/json-schema": "^7.0.15", "js-yaml": "^4.1.0" } }, "sha512-4gY54eEGEstClvEkGnwVkTkrx0sqwemEFG5OSRRn3tD91XH0+Q8XIkYIfo7IwEWPpJZwILb9GUXeShtplRc/eA=="], + "@apidevtools/json-schema-ref-parser": ["@apidevtools/json-schema-ref-parser@14.0.1", "", { "dependencies": { "@types/json-schema": "^7.0.15", "js-yaml": "^4.1.0" } }, "sha512-Oc96zvmxx1fqoSEdUmfmvvb59/KDOnUoJ7s2t7bISyAn0XEz57LCCw8k2Y4Pf3mwKaZLMciESALORLgfe2frCw=="], "@apidevtools/openapi-schemas": ["@apidevtools/openapi-schemas@2.1.0", "", {}, "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ=="], "@apidevtools/swagger-methods": ["@apidevtools/swagger-methods@3.0.2", "", {}, "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg=="], - "@apidevtools/swagger-parser": ["@apidevtools/swagger-parser@10.1.1", "", { "dependencies": { "@apidevtools/json-schema-ref-parser": "11.7.2", "@apidevtools/openapi-schemas": "^2.1.0", "@apidevtools/swagger-methods": "^3.0.2", "@jsdevtools/ono": "^7.1.3", "ajv": "^8.17.1", "ajv-draft-04": "^1.0.0", "call-me-maybe": "^1.0.2" }, "peerDependencies": { "openapi-types": ">=7" } }, "sha512-u/kozRnsPO/x8QtKYJOqoGtC4kH6yg1lfYkB9Au0WhYB0FNLpyFusttQtvhlwjtG3rOwiRz4D8DnnXa8iEpIKA=="], + "@apidevtools/swagger-parser": ["@apidevtools/swagger-parser@12.0.0", "", { "dependencies": { "@apidevtools/json-schema-ref-parser": "14.0.1", "@apidevtools/openapi-schemas": "^2.1.0", "@apidevtools/swagger-methods": "^3.0.2", "ajv": "^8.17.1", "ajv-draft-04": "^1.0.0", "call-me-maybe": "^1.0.2" }, "peerDependencies": { "openapi-types": ">=7" } }, "sha512-WLJIWcfOXrSKlZEM+yhA2Xzatgl488qr1FoOxixYmtWapBzwSC0gVGq4WObr4hHClMIiFFdOBdixNkvWqkWIWA=="], + + "@borewit/text-codec": ["@borewit/text-codec@0.1.1", "", {}, "sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-W8bFfPA8DowP8l//sxjJLSLkD8iEjMc7cBVyP+u4cEv9sM7mdUCkgsj+t0n/BWPFtv7WWCN5Yzj0N6FJNUUqBQ=="], @@ -105,12 +106,10 @@ "@jridgewell/set-array": ["@jridgewell/set-array@1.2.1", "", {}, "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A=="], - "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="], + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="], - "@jsdevtools/ono": ["@jsdevtools/ono@7.1.3", "", {}, "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg=="], - "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], @@ -159,15 +158,19 @@ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.40.1", "", { "os": "win32", "cpu": "x64" }, "sha512-ECyOuDeH3C1I8jH2MK1RtBJW+YPMvSfT0a5NN0nHfQYnDSJ6tUiZH3gzwVP5/Kfh/+Tt7tpWVF9LXNTnhTJ3kA=="], - "@scalar/openapi-types": ["@scalar/openapi-types@0.1.1", "", {}, "sha512-NMy3QNk6ytcCoPUGJH0t4NNr36OWXgZhA3ormr3TvhX1NDgoF95wFyodGVH8xiHeUyn2/FxtETm8UBLbB5xEmg=="], - - "@scalar/themes": ["@scalar/themes@0.9.86", "", { "dependencies": { "@scalar/types": "0.1.7" } }, "sha512-QUHo9g5oSWi+0Lm1vJY9TaMZRau8LHg+vte7q5BVTBnu6NuQfigCaN+ouQ73FqIVd96TwMO6Db+dilK1B+9row=="], + "@scalar/openapi-types": ["@scalar/openapi-types@0.3.7", "", { "dependencies": { "zod": "3.24.1" } }, "sha512-QHSvHBVDze3+dUwAhIGq6l1iOev4jdoqdBK7QpfeN1Q4h+6qpVEw3EEqBiH0AXUSh/iWwObBv4uMgfIx0aNZ5g=="], - "@scalar/types": ["@scalar/types@0.0.12", "", { "dependencies": { "@scalar/openapi-types": "0.1.1", "@unhead/schema": "^1.9.5" } }, "sha512-XYZ36lSEx87i4gDqopQlGCOkdIITHHEvgkuJFrXFATQs9zHARop0PN0g4RZYWj+ZpCUclOcaOjbCt8JGe22mnQ=="], + "@scalar/types": ["@scalar/types@0.2.13", "", { "dependencies": { "@scalar/openapi-types": "0.3.7", "nanoid": "5.1.5", "zod": "3.24.1" } }, "sha512-rO6KGMJqOsBnN/2R4fErMFLpRSPVJElni+HABDpf+ZlLJp2lvxuPn0IXLumK5ytfplUH9iqKgSXjndnZfxSYLQ=="], "@sinclair/typebox": ["@sinclair/typebox@0.34.33", "", {}, "sha512-5HAV9exOMcXRUxo+9iYB5n09XxzCXnfy4VTNW4xnDv+FgjzAGY989C28BIdljKqmF+ZltUwujE3aossvcVtq6g=="], - "@types/bun": ["@types/bun@1.1.14", "", { "dependencies": { "bun-types": "1.1.37" } }, "sha512-opVYiFGtO2af0dnWBdZWlioLBoxSdDO5qokaazLhq8XQtGZbY4pY3/JxY8Zdf/hEwGubbp7ErZXoN1+h2yesxA=="], + "@sinclair/typemap": ["@sinclair/typemap@0.10.1", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.30", "valibot": "^1.0.0", "zod": "^3.24.1" } }, "sha512-UXR0fhu/n3c9B6lB+SLI5t1eVpt9i9CdDrp2TajRe3LbKiUhCTZN2kSfJhjPnpc3I59jMRIhgew7+0HlMi08mg=="], + + "@tokenizer/inflate": ["@tokenizer/inflate@0.2.7", "", { "dependencies": { "debug": "^4.4.0", "fflate": "^0.8.2", "token-types": "^6.0.0" } }, "sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg=="], + + "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], + + "@types/bun": ["@types/bun@1.2.20", "", { "dependencies": { "bun-types": "1.2.20" } }, "sha512-dX3RGzQ8+KgmMw7CsW4xT5ITBSCrSbfHc36SNT31EOUg/LA9JWq0VDdEXDRSe1InVWpd2yLUM1FUF/kEOyTzYA=="], "@types/estree": ["@types/estree@1.0.7", "", {}, "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="], @@ -175,9 +178,7 @@ "@types/node": ["@types/node@20.12.14", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-scnD59RpYD91xngrQQLGkE+6UrHUPzeKZWhhjBSa3HSkwjbQc38+q3RoIVEwxQGRw3M+j5hpNAM+lgV3cVormg=="], - "@types/ws": ["@types/ws@8.5.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-bd/YFLW+URhBzMXurx7lWByOu+xzU9+kb3RboOteXYDfW+tr+JZa99OyNmPINEGB/ahzKrEuc8rcv4gnpJmxTw=="], - - "@unhead/schema": ["@unhead/schema@1.11.20", "", { "dependencies": { "hookable": "^5.5.3", "zhead": "^2.2.4" } }, "sha512-0zWykKAaJdm+/Y7yi/Yds20PrUK7XabLe9c3IRcjnwYmSWY6z0Cr19VIs3ozCj8P+GhR+/TI2mwtGlueCEYouA=="], + "@types/react": ["@types/react@19.1.12", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w=="], "acorn": ["acorn@8.14.1", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg=="], @@ -199,7 +200,7 @@ "brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="], - "bun-types": ["bun-types@1.1.37", "", { "dependencies": { "@types/node": "~20.12.8", "@types/ws": "~8.5.10" } }, "sha512-C65lv6eBr3LPJWFZ2gswyrGZ82ljnH8flVE03xeXxKhi2ZGtFiO4isRKTKnitbSqtRAcaqYSR6djt1whI66AbA=="], + "bun-types": ["bun-types@1.2.20", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-pxTnQYOrKvdOwyiyd/7sMt9yFOenN004Y6O4lCcCUoKVej48FS5cvTw9geRaEcB9TsDZaJKAxPTVvi8tFsVuXA=="], "bundle-require": ["bundle-require@5.1.0", "", { "dependencies": { "load-tsconfig": "^0.2.3" }, "peerDependencies": { "esbuild": ">=0.18" } }, "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA=="], @@ -221,19 +222,23 @@ "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + "confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], + "consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="], "cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], + "debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="], "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], - "elysia": ["elysia@1.3.0-exp.71", "", { "dependencies": { "@sinclair/typebox": "^0.34.33", "cookie": "^1.0.2", "exact-mirror": "0.1.1", "fast-decode-uri-component": "^1.0.1", "openapi-types": "^12.1.3" }, "peerDependencies": { "file-type": ">= 20.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["file-type", "typescript"] }, "sha512-jL7z5OzJgs8pCzEXRmzzYu972S9hILiab7bVD3VBJHAE/9EMdG5uzxWA++3rxJXPEW7HvK3E31zaJKv6TtKgqA=="], + "elysia": ["elysia@1.3.21", "", { "dependencies": { "cookie": "^1.0.2", "exact-mirror": "0.1.6", "fast-decode-uri-component": "^1.0.1" }, "optionalDependencies": { "@sinclair/typebox": "^0.34.33", "openapi-types": "^12.1.3" }, "peerDependencies": { "file-type": ">= 20.0.0", "typescript": ">= 5.0.0" } }, "sha512-LLfDSoVA5fBoqKQfMJyzmHLkya8zMbEYwd7DS7v2iQB706mgzWg0gufXl58cFALErcvSayplrkDvjkmlYTkIZQ=="], "emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], @@ -257,7 +262,7 @@ "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], - "exact-mirror": ["exact-mirror@0.1.1", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-jygrs/z9JT3UBDVPsu4vLy8gqtTLTxVzoxLmDzkVXHizRGixDMdkdLF98ChZxsqHL0F7IcpTf8GUFRqa2qt3uw=="], + "exact-mirror": ["exact-mirror@0.1.6", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-EXGDixoDotCGrXCce63zmGHDA+3Id6PPkIwshBHuB10dwVc4YV4gfaYLuysHOxyURmwyt4UL186ann0oYa2CFQ=="], "fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="], @@ -273,10 +278,16 @@ "fdir": ["fdir@6.4.4", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg=="], + "fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="], + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], + "file-type": ["file-type@21.0.0", "", { "dependencies": { "@tokenizer/inflate": "^0.2.7", "strtok3": "^10.2.2", "token-types": "^6.0.0", "uint8array-extras": "^1.4.0" } }, "sha512-ek5xNX2YBYlXhiUXui3D/BXa3LdqPmoLJ7rqEx2bKJ7EAUEfmXgW0Das7Dc6Nr9MvqaOnIqiPV0mZk/r/UpNAg=="], + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + "fix-dts-default-cjs-exports": ["fix-dts-default-cjs-exports@1.0.1", "", { "dependencies": { "magic-string": "^0.30.17", "mlly": "^1.7.4", "rollup": "^4.34.8" } }, "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg=="], + "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], "flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="], @@ -293,7 +304,7 @@ "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], - "hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="], + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], @@ -341,10 +352,14 @@ "lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "magic-string": ["magic-string@0.30.18", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-yi8swmWbO17qHhwIBNeeZxTceJMeBvWJaId6dyvTSOwTipqeHhMhOrz6513r1sOKnpvQ7zkhlG8tPrpilwTxHQ=="], + "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], + "mlly": ["mlly@1.8.0", "", { "dependencies": { "acorn": "^8.15.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.1" } }, "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="], @@ -373,7 +388,7 @@ "path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], - "pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="], + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], @@ -381,6 +396,8 @@ "pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="], + "pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], + "postcss-load-config": ["postcss-load-config@6.0.1", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "postcss", "tsx", "yaml"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="], "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], @@ -419,6 +436,8 @@ "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], + "strtok3": ["strtok3@10.3.4", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg=="], + "sucrase": ["sucrase@3.35.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "glob": "^10.3.10", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA=="], "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], @@ -433,24 +452,30 @@ "tinyglobby": ["tinyglobby@0.2.13", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw=="], + "token-types": ["token-types@6.1.1", "", { "dependencies": { "@borewit/text-codec": "^0.1.0", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-kh9LVIWH5CnL63Ipf0jhlBIy0UsrMj/NJDfpsy1SqOXlLKEVyXXYrnFxFT1yOOYVGBSApeVnjPw/sBz5BfEjAQ=="], + "tr46": ["tr46@1.0.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA=="], "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="], "ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="], - "tsup": ["tsup@8.4.0", "", { "dependencies": { "bundle-require": "^5.1.0", "cac": "^6.7.14", "chokidar": "^4.0.3", "consola": "^3.4.0", "debug": "^4.4.0", "esbuild": "^0.25.0", "joycon": "^3.1.1", "picocolors": "^1.1.1", "postcss-load-config": "^6.0.1", "resolve-from": "^5.0.0", "rollup": "^4.34.8", "source-map": "0.8.0-beta.0", "sucrase": "^3.35.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.11", "tree-kill": "^1.2.2" }, "peerDependencies": { "@microsoft/api-extractor": "^7.36.0", "@swc/core": "^1", "postcss": "^8.4.12", "typescript": ">=4.5.0" }, "optionalPeers": ["@microsoft/api-extractor", "@swc/core", "postcss", "typescript"], "bin": { "tsup": "dist/cli-default.js", "tsup-node": "dist/cli-node.js" } }, "sha512-b+eZbPCjz10fRryaAA7C8xlIHnf8VnsaRqydheLIqwG/Mcpfk8Z5zp3HayX7GaTygkigHl5cBUs+IhcySiIexQ=="], + "tsup": ["tsup@8.5.0", "", { "dependencies": { "bundle-require": "^5.1.0", "cac": "^6.7.14", "chokidar": "^4.0.3", "consola": "^3.4.0", "debug": "^4.4.0", "esbuild": "^0.25.0", "fix-dts-default-cjs-exports": "^1.0.0", "joycon": "^3.1.1", "picocolors": "^1.1.1", "postcss-load-config": "^6.0.1", "resolve-from": "^5.0.0", "rollup": "^4.34.8", "source-map": "0.8.0-beta.0", "sucrase": "^3.35.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.11", "tree-kill": "^1.2.2" }, "peerDependencies": { "@microsoft/api-extractor": "^7.36.0", "@swc/core": "^1", "postcss": "^8.4.12", "typescript": ">=4.5.0" }, "optionalPeers": ["@microsoft/api-extractor", "@swc/core", "postcss", "typescript"], "bin": { "tsup": "dist/cli-default.js", "tsup-node": "dist/cli-node.js" } }, "sha512-VmBp77lWNQq6PfuMqCHD3xWl22vEoWsKajkF8t+yMBawlUS8JzEI+vOVMeuNZIuMML8qXRizFKi9oD5glKQVcQ=="], "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], - "type-fest": ["type-fest@4.40.1", "", {}, "sha512-9YvLNnORDpI+vghLU/Nf+zSv0kL47KbVJ1o3sKgoTefl6i+zebxbiDQWoe/oWWqPhIgQdRZRT1KA9sCPL810SA=="], + "typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="], + + "ufo": ["ufo@1.6.1", "", {}, "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA=="], - "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], + "uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="], "undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "valibot": ["valibot@1.1.0", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-Nk8lX30Qhu+9txPYTwM0cFlWLdPFsFr6LblzqIySfbZph9+BFsAHsNvHOymEviUepeIW6KFHzpX8TKhbptBXXw=="], + "webidl-conversions": ["webidl-conversions@4.0.2", "", {}, "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg=="], "whatwg-url": ["whatwg-url@7.1.0", "", { "dependencies": { "lodash.sortby": "^4.7.0", "tr46": "^1.0.1", "webidl-conversions": "^4.0.2" } }, "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg=="], @@ -465,9 +490,7 @@ "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], - "zhead": ["zhead@2.2.4", "", {}, "sha512-8F0OI5dpWIA5IGG5NHUg9staDwz/ZPxZtvGVf01j7vHqSyZ0raHY+78atOVxRqb73AotX22uV1pXt3gYSstGag=="], - - "zod": ["zod@3.24.3", "", {}, "sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg=="], + "zod": ["zod@3.24.1", "", {}, "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A=="], "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], @@ -475,7 +498,9 @@ "@isaacs/cliui/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="], - "@scalar/themes/@scalar/types": ["@scalar/types@0.1.7", "", { "dependencies": { "@scalar/openapi-types": "0.2.0", "@unhead/schema": "^1.11.11", "nanoid": "^5.1.5", "type-fest": "^4.20.0", "zod": "^3.23.8" } }, "sha512-irIDYzTQG2KLvFbuTI8k2Pz/R4JR+zUUSykVTbEMatkzMmVFnn1VzNSMlODbadycwZunbnL2tA27AXed9URVjw=="], + "@jridgewell/gen-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="], + + "@jridgewell/trace-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="], "eslint/ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], @@ -483,6 +508,8 @@ "import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + "mlly/acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], + "string-width/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="], "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], @@ -497,8 +524,6 @@ "@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="], - "@scalar/themes/@scalar/types/@scalar/openapi-types": ["@scalar/openapi-types@0.2.0", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-waiKk12cRCqyUCWTOX0K1WEVX46+hVUK+zRPzAahDJ7G0TApvbNkuy5wx7aoUyEk++HHde0XuQnshXnt8jsddA=="], - "eslint/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "glob/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], diff --git a/example/gen.ts b/example/gen.ts new file mode 100644 index 0000000..3b72f36 --- /dev/null +++ b/example/gen.ts @@ -0,0 +1,22 @@ +import { Elysia, t } from 'elysia' +import { openapi } from '../src/index' +import { fromTypes } from '../src/gen' + +export const app = new Elysia() + .use( + openapi({ + references: fromTypes('example/gen.ts') + }) + ) + .get('/', { test: 'hello' as const }) + .post( + '/json', + ({ body, status }) => (Math.random() > 0.5 ? status(418) : body), + { + body: t.Object({ + hello: t.String() + }) + } + ) + .get('/id/:id/name/:name', ({ params }) => params) + .listen(3000) diff --git a/example/index.ts b/example/index.ts index 681bf86..7e8439a 100644 --- a/example/index.ts +++ b/example/index.ts @@ -1,18 +1,28 @@ import { Elysia, t } from 'elysia' -import { swagger } from '../src/index' +import { openapi, withHeaders } from '../src/index' const schema = t.Object({ test: t.Literal('hello') }) -const app = new Elysia({ prefix: '/api' }) +const schema2 = t.Object({ + test: t.Literal('world') +}) + +const user = t.Object({ + name: t.String({ + example: 'saltyaom' + }) +}) + +export const app = new Elysia() .use( - swagger({ + openapi({ provider: 'scalar', documentation: { info: { title: 'Elysia Scalar', - version: '0.8.1' + version: '1.3.1a' }, tags: [ { @@ -21,39 +31,55 @@ const app = new Elysia({ prefix: '/api' }) } ], components: { - schemas: { - User: { - description: 'string' - } - }, securitySchemes: { - JwtAuth: { + bearer: { type: 'http', - scheme: 'bearer', - bearerFormat: 'JWT', - description: 'Enter JWT Bearer token **_only_**' + scheme: 'bearer' + }, + cookie: { + type: 'apiKey', + in: 'cookie', + name: 'session_id' } } } - }, - swaggerOptions: { - persistAuthorization: true } }) ) - .model({ schema }) + .model({ schema, schema2, user }) .get( '/', - () => { - return { test: 'hello' as const } - }, + { test: 'hello' as const }, + { + response: { + 200: t.Object({ + test: t.Literal('hello') + }), + 204: withHeaders( + t.Void({ + title: 'Thing', + description: 'Void response' + }), + { + 'X-Custom-Header': t.Literal('Elysia') + } + ) + } + } + ) + .post( + '/json', + ({ body }) => ({ + test: 'world' + }), { - response: 'schema' + parse: ['json', 'formdata'], + body: 'user', + response: { + 200: 'schema', + 400: 'schema2' + } } ) - .post('/json', ({ body }) => body, { - parse: ['json', 'formdata'], - body: 'schema', - response: 'schema' - }) + .get('/id/:id/name/:name', () => {}) .listen(3000) diff --git a/example/index2.ts b/example/index2.ts deleted file mode 100644 index 19e75b4..0000000 --- a/example/index2.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { Elysia } from 'elysia' -import { swagger } from '../src/index' -import { plugin } from './plugin' - -const app = new Elysia({ - // aot: false -}) - .use( - swagger({ - documentation: { - info: { - title: 'Elysia', - version: '0.6.10' - }, - tags: [ - { - name: 'Test', - description: 'Hello' - } - ], - security: [ - {JwtAuth: []} - ], - components: { - schemas: { - User: { - description: 'string' - } - }, - securitySchemes: { - JwtAuth: { - type: 'http', - scheme: 'bearer', - bearerFormat: 'JWT', - description: 'Enter JWT Bearer token **_only_**' - } - } - } - }, - swaggerOptions: { - persistAuthorization: true - }, - }) - ) - .use(plugin) - .listen(3000) diff --git a/example/index3.ts b/example/index3.ts deleted file mode 100644 index 047339e..0000000 --- a/example/index3.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { Elysia, InternalRoute } from 'elysia' -import { swagger } from '../src/index' -import { plugin } from './plugin' -import { registerSchemaPath } from '../src/utils' - -const app = new Elysia() - .use( - swagger({ - provider: 'scalar', - documentation: { - info: { - title: 'Elysia Scalar', - version: '0.8.1' - }, - tags: [ - { - name: 'Test', - description: 'Hello' - } - ], - paths: { - "/b/": { - get: { - operationId: "getB", - summary: "Ping Pong B", - description: "Lorem Ipsum Dolar", - tags: [ "Test" ], - responses: { - "200": { - description: "test" - }, - }, - }, - }, - }, - components: { - schemas: { - User: { - description: 'string' - } - }, - securitySchemes: { - JwtAuth: { - type: 'http', - scheme: 'bearer', - bearerFormat: 'JWT', - description: 'Enter JWT Bearer token **_only_**' - } - } - } - }, - swaggerOptions: { - persistAuthorization: true - } - }) - ) - .use(plugin) - .listen(3000) - -console.log(app.rsaoutes) diff --git a/package.json b/package.json index da4315d..d360ffb 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "@elysiajs/swagger", - "version": "1.3.1", - "description": "Plugin for Elysia to auto-generate Swagger page", + "name": "@elysiajs/openapi", + "version": "1.3.2", + "description": "Plugin for Elysia to auto-generate API documentation", "author": { "name": "saltyAom", "url": "https://github.com/SaltyAom", @@ -17,15 +17,15 @@ "import": "./dist/index.mjs", "require": "./dist/cjs/index.js" }, - "./types": { - "types": "./dist/types.d.ts", - "import": "./dist/types.mjs", - "require": "./dist/cjs/types.js" + "./gen": { + "types": "./dist/gen/index.d.ts", + "import": "./dist/gen/index.mjs", + "require": "./dist/cjs/gen/index.js" }, - "./utils": { - "types": "./dist/utils.d.ts", - "import": "./dist/utils.mjs", - "require": "./dist/cjs/utils.js" + "./openapi": { + "types": "./dist/openapi.d.ts", + "import": "./dist/openapi.mjs", + "require": "./dist/cjs/openapi.js" }, "./scalar": { "types": "./dist/scalar/index.d.ts", @@ -36,18 +36,35 @@ "types": "./dist/scalar/theme.d.ts", "import": "./dist/scalar/theme.mjs", "require": "./dist/cjs/scalar/theme.js" + }, + "./swagger": { + "types": "./dist/swagger/index.d.ts", + "import": "./dist/swagger/index.mjs", + "require": "./dist/cjs/swagger/index.js" + }, + "./swagger/types": { + "types": "./dist/swagger/types.d.ts", + "import": "./dist/swagger/types.mjs", + "require": "./dist/cjs/swagger/types.js" + }, + "./types": { + "types": "./dist/types.d.ts", + "import": "./dist/types.mjs", + "require": "./dist/cjs/types.js" } }, "keywords": [ "elysia", - "swagger" + "openapi", + "swagger", + "scalar" ], - "homepage": "https://github.com/elysiajs/elysia-swagger", + "homepage": "https://github.com/elysiajs/elysia-openapi", "repository": { "type": "git", - "url": "https://github.com/elysiajs/elysia-swagger" + "url": "https://github.com/elysiajs/elysia-openapi" }, - "bugs": "https://github.com/elysiajs/elysia-swagger/issues", + "bugs": "https://github.com/elysiajs/elysia-openapi/issues", "license": "MIT", "scripts": { "dev": "bun run --watch example/index.ts", @@ -60,17 +77,16 @@ "elysia": ">= 1.3.0" }, "devDependencies": { - "@apidevtools/swagger-parser": "^10.1.0", - "@types/bun": "1.1.14", - "elysia": "1.3.0-exp.71", + "@apidevtools/swagger-parser": "^12.0.0", + "@types/bun": "1.2.20", + "@scalar/types": "^0.2.13", + "elysia": "1.3.21", "eslint": "9.6.0", - "tsup": "^8.1.0", - "typescript": "^5.5.3" + "tsup": "^8.5.0", + "typescript": "^5.9.2" }, "dependencies": { - "@scalar/themes": "^0.9.52", - "@scalar/types": "^0.0.12", - "openapi-types": "^12.1.3", - "pathe": "^1.1.2" + "@sinclair/typemap": "^0.10.1", + "openapi-types": "^12.1.3" } } diff --git a/src/gen/index.ts b/src/gen/index.ts new file mode 100644 index 0000000..84be13c --- /dev/null +++ b/src/gen/index.ts @@ -0,0 +1,192 @@ +import type { InternalRoute } from 'elysia' +import { + readFileSync, + mkdirSync, + writeFileSync, + rmSync, + existsSync, + cpSync +} from 'fs' +import { TypeBox } from '@sinclair/typemap' + +import { tmpdir } from 'os' +import { join } from 'path' +import { spawnSync } from 'child_process' +import { AdditionalReference, AdditionalReferences } from '../types' + +const matchRoute = /: Elysia<(.*)>/gs +const matchStatus = /(\d{3}):/gs +const wrapStatusInQuote = (value: string) => value.replace(matchStatus, '"$1":') + +const exec = (command: string, cwd: string) => + spawnSync(command, { + shell: true, + cwd, + stdio: 'inherit' + }) + +interface OpenAPIGeneratorOptions { + /** + * Path to tsconfig.json + * @default tsconfig.json + */ + tsconfigPath?: string + + /** + * Name of the Elysia instance + * + * If multiple instances are found, + * instanceName should be provided + */ + instanceName?: string + + /** + * Project root directory + * + * @default process.cwd() + */ + projectRoot?: string +} + +/** + * Auto generate OpenAPI schema from Elysia instance + * + * It's expected that this command should run in project root + * + * @experimental use at your own risk + */ +export const fromTypes = + ( + /** + * Path to file where Elysia instance is + * + * The path must export an Elysia instance + */ + targetFilePath: string, + { + tsconfigPath = 'tsconfig.json', + instanceName, + projectRoot = process.cwd() + }: OpenAPIGeneratorOptions = {} + ) => + () => { + if (!targetFilePath.endsWith('.ts') && !targetFilePath.endsWith('.tsx')) + throw new Error('Only .ts files are supported') + + const tmpRoot = join(tmpdir(), '.ElysiaAutoOpenAPI') + + if (existsSync(tmpRoot)) + rmSync(tmpRoot, { recursive: true, force: true }) + mkdirSync(tmpRoot, { recursive: true }) + + const extendsRef = existsSync(join(projectRoot, 'tsconfig.json')) + ? `"extends": "${join(projectRoot, 'tsconfig.json')}",` + : '' + + if (!join(projectRoot, targetFilePath)) + throw new Error('Target file does not exist') + + writeFileSync( + join(tmpRoot, tsconfigPath), + `{ + ${extendsRef} + "compilerOptions": { + "lib": ["ESNext"], + "module": "ESNext", + "noEmit": false, + "moduleResolution": "bundler", + "skipLibCheck": true, + "skipDefaultLibCheck": true, + "emitDeclarationOnly": true, + "outDir": "./dist" + }, + "include": ["${join(projectRoot, targetFilePath)}"] + }` + ) + + exec(`tsc`, tmpRoot) + + try { + const declaration = readFileSync( + join( + tmpRoot, + 'dist', + targetFilePath + .replace(/.tsx$/, '.ts') + .replace(/.ts$/, '.d.ts') + ), + 'utf8' + ) + + // Check just in case of race-condition + if (existsSync(tmpRoot)) + rmSync(tmpRoot, { recursive: true, force: true }) + + let instance = declaration.match( + instanceName + ? new RegExp(`${instanceName}: Elysia<(.*)`, 'gs') + : matchRoute + )?.[0] + + if (!instance) return + + // Get 5th generic parameter + // Elysia<'', {}, {}, {}, Routes> + // ------------------------^ + // 1 2 3 4 5 + // We want the 4th one + for (let i = 0; i < 3; i++) + instance = instance.slice(instance.indexOf('}, {', 3)) + + const routesString = + wrapStatusInQuote(instance).slice( + 3, + instance.indexOf('}, {', 3) + ) + '}\n}\n' + + const routes: AdditionalReference = {} + + for (let route of routesString.slice(1).split('} & {')) { + route = '{' + route + '}' + let schema = TypeBox(route) + + if (schema.type !== 'object') continue + + const paths = [] + + while (true) { + const keys = Object.keys(schema.properties) + if (!keys.length || keys.length > 1) break + + paths.push(keys[0]) + + schema = schema.properties[keys[0]] as any + if (!schema?.properties) break + } + + const method = paths.pop()! + const path = '/' + paths.join('/') + schema = schema.properties + + if (schema?.response?.type === 'object') { + const responseSchema: Record = {} + + for (const key in schema.response.properties) + responseSchema[key] = schema.response.properties[key] + + schema.response = responseSchema + } + + if (!routes[path]) routes[path] = {} + // @ts-ignore + routes[path][method.toLowerCase()] = schema + } + + return routes + } catch (error) { + console.warn('Failed to generate OpenAPI schema') + console.warn(error) + + return + } + } diff --git a/src/index.ts b/src/index.ts index 7dbecc2..15d5483 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,42 +1,35 @@ -/* eslint-disable @typescript-eslint/ban-ts-comment */ import { Elysia, type InternalRoute } from 'elysia' import { SwaggerUIRender } from './swagger' import { ScalarRender } from './scalar' -import { filterPaths, registerSchemaPath } from './utils' +import { toOpenAPISchema } from './openapi' import type { OpenAPIV3 } from 'openapi-types' -import type { ReferenceConfiguration } from '@scalar/types' -import type { ElysiaSwaggerConfig } from './types' +import type { ApiReferenceConfiguration } from '@scalar/types' +import type { ElysiaOpenAPIConfig, OpenAPIProvider } from './types' /** - * Plugin for [elysia](https://github.com/elysiajs/elysia) that auto-generate Swagger page. + * Plugin for [elysia](https://github.com/elysiajs/elysia) that auto-generate OpenAPI documentation page. * * @see https://github.com/elysiajs/elysia-swagger */ -export const swagger = ({ - provider = 'scalar', - scalarVersion = 'latest', - scalarCDN = '', - scalarConfig = {}, - documentation = {}, - version = '5.9.0', - excludeStaticFile = true, - path = '/swagger' as Path, +export const openapi = < + const Enabled extends boolean = true, + const Path extends string = '/openapi', + const Provider extends OpenAPIProvider = 'scalar' +>({ + enabled = true as Enabled, + path = '/openapi' as Path, + provider = 'scalar' as Provider, specPath = `${path}/json`, - exclude = [], - swaggerOptions = {}, - theme = `https://unpkg.com/swagger-ui-dist@${version}/swagger-ui.css`, - autoDarkMode = true, - excludeMethods = ['OPTIONS'], - excludeTags = [] -}: ElysiaSwaggerConfig = {}) => { - const schema = {} - let totalRoutes = 0 - - if (!version) - version = `https://unpkg.com/swagger-ui-dist@${version}/swagger-ui.css` + documentation = {}, + exclude, + swagger, + scalar, + references +}: ElysiaOpenAPIConfig = {}) => { + if (!enabled) return new Elysia({ name: '@elysiajs/openapi' }) const info = { title: 'Elysia Documentation', @@ -47,145 +40,94 @@ export const swagger = ({ const relativePath = specPath.startsWith('/') ? specPath.slice(1) : specPath - const app = new Elysia({ name: '@elysiajs/swagger' }) - - const page = new Response( - provider === 'swagger-ui' - ? SwaggerUIRender( - info, - version, - theme, - JSON.stringify( - { - url: relativePath, - dom_id: '#swagger-ui', - ...swaggerOptions - }, - (_, value) => - typeof value === 'function' ? undefined : value - ), - autoDarkMode - ) - : ScalarRender( - info, - scalarVersion, + let totalRoutes = 0 + let cachedSchema: OpenAPIV3.Document | undefined + + const app = new Elysia({ name: '@elysiajs/openapi' }) + .use((app) => { + if (provider === null) return app + + return app.get( + path, + new Response( + provider === 'swagger-ui' + ? SwaggerUIRender(info, { + url: relativePath, + dom_id: '#swagger-ui', + version: 'latest', + autoDarkMode: true, + ...swagger + }) + : ScalarRender(info, { + url: relativePath, + version: 'latest', + cdn: `https://cdn.jsdelivr.net/npm/@scalar/api-reference@${scalar?.version ?? 'latest'}/dist/browser/standalone.min.js`, + ...(scalar as ApiReferenceConfiguration), + _integration: 'elysiajs' + }), { - spec: { - url: relativePath, - ...scalarConfig.spec - }, - ...scalarConfig, - // so we can showcase the elysia theme - // @ts-expect-error - _integration: 'elysiajs' - } satisfies ReferenceConfiguration, - scalarCDN + headers: { + 'content-type': 'text/html; charset=utf8' + } + } ), - { - headers: { - 'content-type': 'text/html; charset=utf8' - } - } - ) - - app.get(path, page, { - detail: { - hide: true - } - }).get( - specPath, - function openAPISchema() { - // @ts-expect-error Private property - const routes = app.getGlobalRoutes() as InternalRoute[] - - if (routes.length !== totalRoutes) { - const ALLOWED_METHODS = [ - 'GET', - 'PUT', - 'POST', - 'DELETE', - 'OPTIONS', - 'HEAD', - 'PATCH', - 'TRACE' - ] - totalRoutes = routes.length - - // forEach create a clone of a route (can't use for-of) - routes.forEach((route: InternalRoute) => { - if (route.hooks?.detail?.hide === true) return - if (excludeMethods.includes(route.method)) return - if ( - ALLOWED_METHODS.includes(route.method) === false && - route.method !== 'ALL' - ) - return - - if (route.method === 'ALL') - ALLOWED_METHODS.forEach((method) => { - registerSchemaPath({ - schema, - hook: route.hooks, - method, - path: route.path, - // @ts-ignore - models: app.getGlobalDefinitions?.().type, - contentType: route.hooks.type - }) - }) - else - registerSchemaPath({ - schema, - hook: route.hooks, - method: route.method, - path: route.path, - // @ts-ignore - models: app.getGlobalDefinitions?.().type, - contentType: route.hooks.type - }) - }) - } - - return { - openapi: '3.0.3', - ...{ + { + detail: { + hide: true + } + } + ) + }) + .get( + specPath, + function openAPISchema() { + if (totalRoutes === app.routes.length) return cachedSchema + + totalRoutes = app.routes.length + + const { + paths, + components: { schemas } + } = toOpenAPISchema(app, exclude, references) + + return (cachedSchema = { + openapi: '3.0.3', ...documentation, - tags: documentation.tags?.filter( - (tag) => !excludeTags?.includes(tag?.name) - ), + tags: !exclude?.tags + ? documentation.tags + : documentation.tags?.filter( + (tag) => !exclude.tags?.includes(tag.name) + ), info: { title: 'Elysia Documentation', description: 'Development documentation', version: '0.0.0', ...documentation.info + }, + paths: { + ...paths, + ...documentation.paths + }, + components: { + ...documentation.components, + schemas: { + ...schemas, + ...documentation.components?.schemas + } } - }, - paths: { - ...filterPaths(schema, { - excludeStaticFile, - exclude: Array.isArray(exclude) ? exclude : [exclude] - }), - ...documentation.paths - }, - components: { - ...documentation.components, - schemas: { - // @ts-ignore - ...app.getGlobalDefinitions?.().type, - ...documentation.components?.schemas - } + } satisfies OpenAPIV3.Document) + }, + { + detail: { + hide: true } - } satisfies OpenAPIV3.Document - }, - { - detail: { - hide: true } - } - ) + ) return app } -export type { ElysiaSwaggerConfig } -export default swagger +export { toOpenAPISchema, withHeaders } from './openapi' +export type { ElysiaOpenAPIConfig } + +export default openapi diff --git a/src/openapi.ts b/src/openapi.ts new file mode 100644 index 0000000..d4ff9d0 --- /dev/null +++ b/src/openapi.ts @@ -0,0 +1,381 @@ +import { t, type AnyElysia, type TSchema, type InputSchema } from 'elysia' +import type { HookContainer } from 'elysia/types' + +import type { OpenAPIV3 } from 'openapi-types' +import type { TProperties } from '@sinclair/typebox' + +import type { + AdditionalReference, + AdditionalReferences, + ElysiaOpenAPIConfig +} from './types' + +export const capitalize = (word: string) => + word.charAt(0).toUpperCase() + word.slice(1) + +const toRef = (name: string) => t.Ref(`#/components/schemas/${name}`) + +const toOperationId = (method: string, paths: string) => { + let operationId = method.toLowerCase() + + if (!paths || paths === '/') return operationId + 'Index' + + for (const path of paths.split('/')) + operationId += path.includes(':') + ? 'By' + capitalize(path.replace(':', '')) + : capitalize(path) + + operationId = operationId.replace(/\?/g, 'Optional') + + return operationId +} + +const optionalParamsRegex = /(\/:\w+\?)/g + +/** + * Get all possible paths of a path with optional parameters + * @param {string} path + * @returns {string[]} paths + */ +export const getPossiblePath = (path: string): string[] => { + const optionalParams = path.match(optionalParamsRegex) + if (!optionalParams) return [path] + + const originalPath = path.replace(/\?/g, '') + const paths = [originalPath] + + for (let i = 0; i < optionalParams.length; i++) { + const newPath = path.replace(optionalParams[i], '') + + paths.push(...getPossiblePath(newPath)) + } + + return paths +} + +/** + * Converts Elysia routes to OpenAPI 3.0.3 paths schema + * @param routes Array of Elysia route objects + * @returns OpenAPI paths object + */ +export function toOpenAPISchema( + app: AnyElysia, + exclude?: ElysiaOpenAPIConfig['exclude'], + references?: AdditionalReferences +) { + const { + methods: excludeMethods = ['OPTIONS'], + staticFile: excludeStaticFile = true, + tags: excludeTags + } = exclude ?? {} + + const excludePaths = Array.isArray(exclude?.paths) + ? exclude.paths + : typeof exclude?.paths !== 'undefined' + ? [exclude.paths] + : [] + + const paths: OpenAPIV3.PathsObject = Object.create(null) + + // @ts-ignore private property + const routes = app.getGlobalRoutes() + + if (references) { + if (!Array.isArray(references)) references = [references] + + for (let i = 0; i < references.length; i++) { + const reference = references[i] + + if (typeof reference === 'function') references[i] = reference() + } + } + + for (const route of routes) { + if (route.hooks?.detail?.hide) continue + + const method = route.method.toLowerCase() + + if ( + (excludeStaticFile && route.path.includes('.')) || + excludePaths.includes(route.path) || + excludeMethods.includes(method) + ) + continue + + const hooks: InputSchema & { + detail: Partial + } = route.hooks ?? {} + + if (references) + for (const reference of references as AdditionalReference[]) { + const refer = reference[route.path]?.[method] + if (!refer) continue + + if (!hooks.body && refer.body) hooks.body = refer.body + if (!hooks.query && refer.query) hooks.query = refer.query + if (!hooks.params && refer.params) hooks.params = refer.params + if (!hooks.headers && refer.headers) + hooks.headers = refer.headers + if (!hooks.response && refer.response) { + hooks.response = {} + + for (const [status, schema] of Object.entries( + refer.response + )) + if (!hooks.response[status as any]) + hooks.response[status as any] = schema + } + } + + if ( + excludeTags && + hooks.detail.tags?.some((tag) => excludeTags?.includes(tag)) + ) + continue + + // Start building the operation object + const operation: Partial = { + ...hooks.detail + } + + const parameters: Array<{ + name: string + in: 'path' | 'query' | 'header' | 'cookie' + required?: boolean + schema: any + }> = [] + + // Handle path parameters + if (hooks.params) { + if (typeof hooks.params === 'string') + hooks.params = toRef(hooks.params) + + if (hooks.params.type === 'object' && hooks.params.properties) { + for (const [paramName, paramSchema] of Object.entries( + hooks.params.properties + )) + parameters.push({ + name: paramName, + in: 'path', + required: true, // Path parameters are always required + schema: paramSchema + }) + } + } + + // Handle query parameters + if (hooks.query) { + if (typeof hooks.query === 'string') + hooks.query = toRef(hooks.query) + + if (hooks.query.type === 'object' && hooks.query.properties) { + const required = hooks.query.required || [] + for (const [queryName, querySchema] of Object.entries( + hooks.query.properties + )) + parameters.push({ + name: queryName, + in: 'query', + required: required.includes(queryName), + schema: querySchema + }) + } + } + + // Handle header parameters + if (hooks.headers) { + if (typeof hooks.headers === 'string') + hooks.headers = toRef(hooks.headers) + + if (hooks.headers.type === 'object' && hooks.headers.properties) { + const required = hooks.headers.required || [] + for (const [headerName, headerSchema] of Object.entries( + hooks.headers.properties + )) + parameters.push({ + name: headerName, + in: 'header', + required: required.includes(headerName), + schema: headerSchema + }) + } + } + + // Handle cookie parameters + if (hooks.cookie) { + if (typeof hooks.cookie === 'string') + hooks.cookie = toRef(hooks.cookie) + + if (hooks.cookie.type === 'object' && hooks.cookie.properties) { + const required = hooks.cookie.required || [] + for (const [cookieName, cookieSchema] of Object.entries( + hooks.cookie.properties + )) + parameters.push({ + name: cookieName, + in: 'cookie', + required: required.includes(cookieName), + schema: cookieSchema + }) + } + } + + // Add parameters if any exist + if (parameters.length > 0) operation.parameters = parameters + + // Handle request body + if (hooks.body) { + if (typeof hooks.body === 'string') hooks.body = toRef(hooks.body) + + // @ts-ignore + if (hooks.parse) { + const content: Record = {} + + // @ts-ignore + const parsers = hooks.parse as HookContainer[] + + for (const parser of parsers) { + if (typeof parser.fn === 'function') continue + + switch (parser.fn) { + case 'text': + case 'text/plain': + content['text/plain'] = { schema: hooks.body } + continue + + case 'urlencoded': + case 'application/x-www-form-urlencoded': + content['application/x-www-form-urlencoded'] = { + schema: hooks.body + } + continue + + case 'json': + case 'application/json': + content['application/json'] = { schema: hooks.body } + continue + + case 'formdata': + case 'multipart/form-data': + content['multipart/form-data'] = { + schema: hooks.body + } + continue + } + } + + operation.requestBody = { content, required: true } + } else { + operation.requestBody = { + content: { + 'application/json': { + schema: hooks.body + }, + 'application/x-www-form-urlencoded': { + schema: hooks.body + }, + 'multipart/form-data': { + schema: hooks.body + } + }, + required: true + } + } + } + + // Handle responses + if (hooks.response) { + operation.responses = {} + + if ( + typeof hooks.response === 'object' && + !(hooks.response as TSchema).type && + !(hooks.response as TSchema).$ref + ) { + for (let [status, schema] of Object.entries(hooks.response)) { + if (typeof schema === 'string') schema = toRef(schema) + + // Must exclude $ref from root options + const { type, examples, $ref, ...options } = schema + + operation.responses[status] = { + description: `Response for status ${status}`, + ...options, + content: + type === 'void' || + type === 'null' || + type === 'undefined' + ? schema + : { + 'application/json': { + schema + } + } + } + } + } else { + if (typeof hooks.response === 'string') + hooks.response = toRef(hooks.response) + + // It's a single schema, default to 200 + operation.responses['200'] = { + description: 'Successful response', + content: { + 'application/json': { + schema: hooks.response + } + } + } + } + } + + for (let path of getPossiblePath(route.path)) { + const operationId = toOperationId(route.method, path) + + path = path.replace(/:([^/]+)/g, '{$1}') + + if (!paths[path]) paths[path] = {} + + const current = paths[path] as any + + if (method !== 'all') { + current[method] = { + ...operation, + operationId + } + continue + } + + // Handle 'ALL' method by assigning operation to all standard methods + for (const method of [ + 'get', + 'post', + 'put', + 'delete', + 'patch', + 'head', + 'options', + 'trace' + ]) + current[method] = { + ...operation, + operationId + } + } + } + + // @ts-ignore private property + const schemas = app.getGlobalDefinitions?.().type + + return { + components: { + schemas + }, + paths + } satisfies Pick +} + +export const withHeaders = (schema: TSchema, headers: TProperties) => + Object.assign(schema, { + headers: headers + }) diff --git a/src/scalar/index.ts b/src/scalar/index.ts index 883ea66..962f3fc 100644 --- a/src/scalar/index.ts +++ b/src/scalar/index.ts @@ -1,12 +1,131 @@ -import { elysiajsTheme } from '@scalar/themes' import type { OpenAPIV3 } from 'openapi-types' -import type { ReferenceConfiguration } from '@scalar/types' +import type { ApiReferenceConfiguration } from '@scalar/types' +import { ElysiaOpenAPIConfig } from '../types' + +const elysiaCSS = `.light-mode { + --scalar-color-1: #2a2f45; + --scalar-color-2: #757575; + --scalar-color-3: #8e8e8e; + --scalar-color-accent: #f06292; + + --scalar-background-1: #fff; + --scalar-background-2: #f6f6f6; + --scalar-background-3: #e7e7e7; + + --scalar-border-color: rgba(0, 0, 0, 0.1); +} +.dark-mode { + --scalar-color-1: rgba(255, 255, 255, 0.9); + --scalar-color-2: rgba(156, 163, 175, 1); + --scalar-color-3: rgba(255, 255, 255, 0.44); + --scalar-color-accent: #f06292; + + --scalar-background-1: #111728; + --scalar-background-2: #1e293b; + --scalar-background-3: #334155; + --scalar-background-accent: #f062921f; + + --scalar-border-color: rgba(255, 255, 255, 0.1); +} + +/* Document Sidebar */ +.light-mode .t-doc__sidebar, +.dark-mode .t-doc__sidebar { + --scalar-sidebar-background-1: var(--scalar-background-1); + --scalar-sidebar-color-1: var(--scalar-color-1); + --scalar-sidebar-color-2: var(--scalar-color-2); + --scalar-sidebar-border-color: var(--scalar-border-color); + + --scalar-sidebar-item-hover-background: var(--scalar-background-2); + --scalar-sidebar-item-hover-color: currentColor; + + --scalar-sidebar-item-active-background: #f062921f; + --scalar-sidebar-color-active: var(--scalar-color-accent); + + --scalar-sidebar-search-background: transparent; + --scalar-sidebar-search-color: var(--scalar-color-3); + --scalar-sidebar-search-border-color: var(--scalar-border-color); +} + +/* advanced */ +.light-mode { + --scalar-button-1: rgb(49 53 56); + --scalar-button-1-color: #fff; + --scalar-button-1-hover: rgb(28 31 33); + + --scalar-color-green: #069061; + --scalar-color-red: #ef0006; + --scalar-color-yellow: #edbe20; + --scalar-color-blue: #0082d0; + --scalar-color-orange: #fb892c; + --scalar-color-purple: #5203d1; + + --scalar-scrollbar-color: rgba(0, 0, 0, 0.18); + --scalar-scrollbar-color-active: rgba(0, 0, 0, 0.36); +} +.dark-mode { + --scalar-button-1: #f6f6f6; + --scalar-button-1-color: #000; + --scalar-button-1-hover: #e7e7e7; + + --scalar-color-green: #a3ffa9; + --scalar-color-red: #ffa3a3; + --scalar-color-yellow: #fffca3; + --scalar-color-blue: #a5d6ff; + --scalar-color-orange: #e2ae83; + --scalar-color-purple: #d2a8ff; + + --scalar-scrollbar-color: rgba(255, 255, 255, 0.24); + --scalar-scrollbar-color-active: rgba(255, 255, 255, 0.48); +} +.section-flare { + width: 100%; + height: 400px; + position: absolute; +} +.section-flare-item:first-of-type:before { + content: ""; + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + --stripes: repeating-linear-gradient(100deg, #fff 0%, #fff 0%, transparent 2%, transparent 12%, #fff 17%); + --stripesDark: repeating-linear-gradient(100deg, #000 0%, #000 0%, transparent 10%, transparent 12%, #000 17%); + --rainbow: repeating-linear-gradient(100deg, #60a5fa 10%, #e879f9 16%, #5eead4 22%, #60a5fa 30%); + contain: strict; + contain-intrinsic-size: 100vw 40vh; + background-image: var(--stripesDark), var(--rainbow); + background-size: 300%, 200%; + background-position: 50% 50%, 50% 50%; + filter: opacity(20%) saturate(200%); + -webkit-mask-image: radial-gradient(ellipse at 100% 0%, black 40%, transparent 70%); + mask-image: radial-gradient(ellipse at 100% 0%, black 40%, transparent 70%); + pointer-events: none; +} +.section-flare-item:first-of-type:after { + content: ""; + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + background-image: var(--stripes), var(--rainbow); + background-size: 200%, 100%; + background-attachment: fixed; + mix-blend-mode: difference; + background-image: var(--stripesDark), var(--rainbow); + pointer-events: none; +} +.light-mode .section-flare-item:first-of-type:after, +.light-mode .section-flare-item:first-of-type:before { + background-image: var(--stripes), var(--rainbow); + filter: opacity(4%) saturate(200%); +}` export const ScalarRender = ( - info: OpenAPIV3.InfoObject, - version: string, - config: ReferenceConfiguration, - cdn: string + info: OpenAPIV3.InfoObject, + config: NonNullable ) => ` @@ -29,20 +148,15 @@ export const ScalarRender = ( } - + ` diff --git a/src/swagger/index.ts b/src/swagger/index.ts index 60a0144..01d3e3a 100644 --- a/src/swagger/index.ts +++ b/src/swagger/index.ts @@ -1,79 +1,101 @@ -import { OpenAPIV3 } from 'openapi-types'; +import { OpenAPIV3 } from 'openapi-types' +import { ElysiaOpenAPIConfig } from '../types' +import { SwaggerUIOptions } from './types' type DateTimeSchema = { - type: 'string'; - format: 'date-time'; - default?: string; -}; + type: 'string' + format: 'date-time' + default?: string +} -type SchemaObject = OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject; +type SchemaObject = OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject -function isSchemaObject(schema: SchemaObject): schema is OpenAPIV3.SchemaObject { - return 'type' in schema || 'properties' in schema || 'items' in schema; +function isSchemaObject( + schema: SchemaObject +): schema is OpenAPIV3.SchemaObject { + return 'type' in schema || 'properties' in schema || 'items' in schema } -function isDateTimeProperty(key: string, schema: OpenAPIV3.SchemaObject): boolean { - return (key === 'createdAt' || key === 'updatedAt') && - 'anyOf' in schema && - Array.isArray(schema.anyOf); +function isDateTimeProperty( + key: string, + schema: OpenAPIV3.SchemaObject +): boolean { + return ( + (key === 'createdAt' || key === 'updatedAt') && + 'anyOf' in schema && + Array.isArray(schema.anyOf) + ) } -function transformDateProperties(schema: SchemaObject): SchemaObject { - if (!isSchemaObject(schema) || typeof schema !== 'object' || schema === null) { - return schema; - } +export function transformDateProperties(schema: SchemaObject): SchemaObject { + if ( + !isSchemaObject(schema) || + typeof schema !== 'object' || + schema === null + ) + return schema + + const newSchema: OpenAPIV3.SchemaObject = { ...schema } - const newSchema: OpenAPIV3.SchemaObject = { ...schema }; + Object.entries(newSchema).forEach(([key, value]) => { + if (isSchemaObject(value)) { + if (isDateTimeProperty(key, value)) { + const dateTimeFormat = value.anyOf?.find( + (item): item is OpenAPIV3.SchemaObject => + isSchemaObject(item) && item.format === 'date-time' + ) - Object.entries(newSchema).forEach(([key, value]) => { - if (isSchemaObject(value)) { - if (isDateTimeProperty(key, value)) { - const dateTimeFormat = value.anyOf?.find((item): item is OpenAPIV3.SchemaObject => - isSchemaObject(item) && item.format === 'date-time' - ); - if (dateTimeFormat) { - const dateTimeSchema: DateTimeSchema = { - type: 'string', - format: 'date-time', - default: dateTimeFormat.default - }; - (newSchema as Record)[key] = dateTimeSchema; - } - } else { - (newSchema as Record)[key] = transformDateProperties(value); - } - } - }); + if (dateTimeFormat) { + const dateTimeSchema: DateTimeSchema = { + type: 'string', + format: 'date-time', + default: dateTimeFormat.default + } + ;(newSchema as Record)[key] = + dateTimeSchema + } + } else { + ;(newSchema as Record)[key] = + transformDateProperties(value) + } + } + }) - return newSchema; + return newSchema } export const SwaggerUIRender = ( - info: OpenAPIV3.InfoObject, - version: string, - theme: - | string - | { - light: string - dark: string - }, - stringifiedSwaggerOptions: string, - autoDarkMode?: boolean + info: OpenAPIV3.InfoObject, + config: NonNullable & SwaggerUIOptions ): string => { - const swaggerOptions: OpenAPIV3.Document = JSON.parse(stringifiedSwaggerOptions); + const { + version = 'latest', + theme = `https://unpkg.com/swagger-ui-dist@${version ?? 'latest'}/swagger-ui.css`, + cdn = `https://unpkg.com/swagger-ui-dist@${version}/swagger-ui-bundle.js`, + autoDarkMode = true, + ...rest + } = config - if (swaggerOptions.components && swaggerOptions.components.schemas) { - swaggerOptions.components.schemas = Object.fromEntries( - Object.entries(swaggerOptions.components.schemas).map(([key, schema]) => [ - key, - transformDateProperties(schema) - ]) - ); - } + // remove function in rest + const stringifiedOptions = JSON.stringify( + { + dom_id: '#swagger-ui', + ...rest + }, + (_, value) => (typeof value === 'function' ? undefined : value) + ) + + const options: OpenAPIV3.Document = JSON.parse(stringifiedOptions) - const transformedStringifiedSwaggerOptions = JSON.stringify(swaggerOptions); + if (options.components && options.components.schemas) + options.components.schemas = Object.fromEntries( + Object.entries(options.components.schemas).map(([key, schema]) => [ + key, + transformDateProperties(schema) + ]) + ) - return ` + return ` @@ -88,40 +110,39 @@ export const SwaggerUIRender = ( content="${info.description}" /> ${ - autoDarkMode && typeof theme === 'string' - ? ` - ` - : '' + .swagger-ui .microlight { + filter: invert(100%) hue-rotate(180deg); } +} +` + : '' + } ${ - typeof theme === 'string' - ? `` - : ` + typeof theme === 'string' + ? `` + : ` ` - } + }
- + -`; -}; +` +} diff --git a/src/types.ts b/src/types.ts index ce16c26..66d2387 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,90 +1,136 @@ +import type { TSchema } from 'elysia' import type { OpenAPIV3 } from 'openapi-types' -import type { ReferenceConfiguration } from '@scalar/types' +import type { ApiReferenceConfiguration } from '@scalar/types' import type { SwaggerUIOptions } from './swagger/types' -export interface ElysiaSwaggerConfig { +export type OpenAPIProvider = 'scalar' | 'swagger-ui' | null + +type MaybeArray = T | T[] + +export type AdditionalReference = { + [path in string]: { + [method in string]: { + params: TSchema + query: TSchema + headers: TSchema + body: TSchema + response: { [status in number]: TSchema } + } + } +} + +export type AdditionalReferences = MaybeArray< + AdditionalReference | undefined | (() => AdditionalReference | undefined) +> + +export interface ElysiaOpenAPIConfig< + Enabled extends boolean = true, + Path extends string = '/swagger', + Provider extends OpenAPIProvider = 'scalar' +> { /** - * Customize Swagger config, refers to Swagger 2.0 config + * @default true + */ + enabled?: Enabled + + /** + * OpenAPI config * - * @see https://swagger.io/specification/v2/ + * @see https://spec.openapis.org/oas/v3.0.3.html */ documentation?: Omit< Partial, | 'x-express-openapi-additional-middleware' | 'x-express-openapi-validation-strict' > + + exclude?: { + /** + * Exclude methods from OpenAPI + */ + methods?: string[] + + /** + * Paths to exclude from OpenAPI endpoint + * + * @default [] + */ + paths?: string | RegExp | (string | RegExp)[] + + /** + * Determine if OpenAPI should exclude static files. + * + * @default true + */ + staticFile?: boolean + + /** + * Exclude tags from OpenAPI + */ + tags?: string[] + } + /** - * Choose your provider, Scalar or Swagger UI + * The endpoint to expose OpenAPI Documentation * - * @default 'scalar' - * @see https://github.com/scalar/scalar - * @see https://github.com/swagger-api/swagger-ui + * @default '/openapi' */ - provider?: 'scalar' | 'swagger-ui' + path?: Path + /** - * Version to use for Scalar cdn bundle + * Choose your provider, Scalar or Swagger UI * - * @default 'latest' + * @default 'scalar' * @see https://github.com/scalar/scalar + * @see https://github.com/swagger-api/swagger-ui */ - scalarVersion?: string + provider?: Provider + /** - * Optional override to specifying the path for the Scalar bundle - * - * Custom URL or path to locally hosted Scalar bundle - * - * Lease blank to use default jsdeliver.net CDN - * - * @default '' - * @example 'https://unpkg.com/@scalar/api-reference@1.13.10/dist/browser/standalone.js' - * @example '/public/standalone.js' - * @see https://github.com/scalar/scalar + * Additional reference for each endpoint */ - scalarCDN?: string + references?: AdditionalReferences + /** * Scalar configuration to customize scalar *' * @see https://github.com/scalar/scalar/blob/main/documentation/configuration.md */ - scalarConfig?: ReferenceConfiguration - /** - * Version to use for swagger cdn bundle - * - * @see unpkg.com/swagger-ui-dist - * - * @default 4.18.2 - */ - version?: string - /** - * Determine if Swagger should exclude static files. - * - * @default true - */ - excludeStaticFile?: boolean - /** - * The endpoint to expose OpenAPI Documentation - * - * @default '/swagger' - */ - path?: Path + scalar?: ApiReferenceConfiguration & { + /** + * Version to use for Scalar cdn bundle + * + * @default 'latest' + * @see https://github.com/scalar/scalar + */ + version?: string + /** + * Optional override to specifying the path for the Scalar bundle + * + * Custom URL or path to locally hosted Scalar bundle + * + * Lease blank to use default jsdeliver.net CDN + * + * @default '' + * @example 'https://unpkg.com/@scalar/api-reference@1.13.10/dist/browser/standalone.js' + * @example '/public/standalone.js' + * @see https://github.com/scalar/scalar + */ + cdn?: string + } /** * The endpoint to expose OpenAPI JSON specification * * @default '/${path}/json' */ specPath?: string - /** - * Paths to exclude from Swagger endpoint - * - * @default [] - */ - exclude?: string | RegExp | (string | RegExp)[] + /** * Options to send to SwaggerUIBundle * Currently, options that are defined as functions such as requestInterceptor * and onComplete are not supported. */ - swaggerOptions?: Omit< + swagger?: Omit< Partial, | 'dom_id' | 'dom_node' @@ -100,28 +146,35 @@ export interface ElysiaSwaggerConfig { | 'responseInterceptor' | 'modelPropertyMacro' | 'parameterMacro' - > - /** - * Custom Swagger CSS - */ - theme?: - | string - | { - light: string - dark: string - } - /** - * Using poor man dark mode 😭 - */ - autoDarkMode?: boolean + > & { + /** + * Custom Swagger CSS + */ + theme?: + | string + | { + light: string + dark: string + } - /** - * Exclude methods from Swagger - */ - excludeMethods?: string[] + /** + * Version to use for swagger cdn bundle + * + * @see unpkg.com/swagger-ui-dist + * + * @default 4.18.2 + */ + version?: string - /** - * Exclude tags from Swagger or Scalar - */ - excludeTags?: string[] + /** + * Using poor man dark mode 😭 + */ + autoDarkMode?: boolean + + /** + * Optional override to specifying the path for the Swagger UI bundle + * Custom URL or path to locally hosted Swagger UI bundle + */ + cdn?: string + } } diff --git a/src/utils.ts b/src/utils.ts deleted file mode 100644 index 19f5170..0000000 --- a/src/utils.ts +++ /dev/null @@ -1,430 +0,0 @@ -/* eslint-disable @typescript-eslint/ban-ts-comment */ -/* eslint-disable @typescript-eslint/no-unused-vars */ -import { normalize } from 'pathe' -import { replaceSchemaType, t, type HTTPMethod, type LocalHook } from 'elysia' - -import { Kind, type TSchema } from '@sinclair/typebox' -import type { OpenAPIV3 } from 'openapi-types' - -export const toOpenAPIPath = (path: string) => - path - .split('/') - .map((x) => { - if (x.startsWith(':')) { - x = x.slice(1, x.length) - if (x.endsWith('?')) x = x.slice(0, -1) - x = `{${x}}` - } - - return x - }) - .join('/') - -export const mapProperties = ( - name: string, - schema: TSchema | string | undefined, - models: Record -) => { - if (schema === undefined) return [] - - if (typeof schema === 'string') - if (schema in models) schema = models[schema] - else throw new Error(`Can't find model ${schema}`) - - return Object.entries(schema?.properties ?? []).map(([key, value]) => { - const { - type: valueType = undefined, - description, - examples, - ...schemaKeywords - } = value as any - return { - // @ts-ignore - description, - examples, - schema: { type: valueType, ...schemaKeywords }, - in: name, - name: key, - // @ts-ignore - required: schema!.required?.includes(key) ?? false - } - }) -} - -const mapTypesResponse = ( - types: string[], - schema: - | string - | { - type: string - properties: Object - required: string[] - } -) => { - if ( - typeof schema === 'object' && - ['void', 'undefined', 'null'].includes(schema.type) - ) - return - - const responses: Record = {} - - for (const type of types) { - responses[type] = { - schema: - typeof schema === 'string' - ? { - $ref: `#/components/schemas/${schema}` - } - : '$ref' in schema && - Kind in schema && - schema[Kind] === 'Ref' - ? { - ...schema, - $ref: `#/components/schemas/${schema.$ref}` - } - : replaceSchemaType( - { ...(schema as any) }, - { - from: t.Ref(''), - // @ts-expect-error - to: ({ $ref, ...options }) => { - if ( - !$ref.startsWith( - '#/components/schemas/' - ) - ) - return t.Ref( - `#/components/schemas/${$ref}`, - options - ) - - return t.Ref($ref, options) - } - } - ) - } - } - - return responses -} - -export const capitalize = (word: string) => - word.charAt(0).toUpperCase() + word.slice(1) - -export const generateOperationId = (method: string, paths: string) => { - let operationId = method.toLowerCase() - - if (paths === '/') return operationId + 'Index' - - for (const path of paths.split('/')) { - if (path.charCodeAt(0) === 123) { - operationId += 'By' + capitalize(path.slice(1, -1)) - } else { - operationId += capitalize(path) - } - } - - return operationId -} - -const cloneHook = (hook: T) => { - if (!hook) return - if (typeof hook === 'string') return hook - if (Array.isArray(hook)) return [...hook] - return { ...hook } -} - -export const registerSchemaPath = ({ - schema, - path, - method, - hook, - models -}: { - schema: Partial - contentType?: string | string[] - path: string - method: HTTPMethod - hook?: LocalHook - models: Record -}) => { - hook = cloneHook(hook) - - if (hook.parse && !Array.isArray(hook.parse)) hook.parse = [hook.parse] - - let contentType = (hook.parse as unknown[]) - ?.map((x) => { - switch (typeof x) { - case 'string': - return x - - case 'object': - if ( - x && - typeof (x as { fn: string | Function })?.fn !== 'string' - ) - return - - switch ((x as { fn: string | Function })?.fn) { - case 'json': - case 'application/json': - return 'application/json' - - case 'text': - case 'text/plain': - return 'text/plain' - - case 'urlencoded': - case 'application/x-www-form-urlencoded': - return 'application/x-www-form-urlencoded' - - case 'arrayBuffer': - case 'application/octet-stream': - return 'application/octet-stream' - - case 'formdata': - case 'multipart/form-data': - return 'multipart/form-data' - } - } - }) - .filter((x) => x !== undefined) - - if (!contentType || contentType.length === 0) - contentType = ['application/json', 'multipart/form-data', 'text/plain'] - - path = toOpenAPIPath(path) - - const contentTypes = - typeof contentType === 'string' - ? [contentType] - : (contentType ?? ['application/json']) - - const bodySchema = cloneHook(hook?.body) - const paramsSchema = cloneHook(hook?.params) - const headerSchema = cloneHook(hook?.headers) - const querySchema = cloneHook(hook?.query) - let responseSchema: OpenAPIV3.ResponsesObject = cloneHook(hook?.response) - - if (typeof responseSchema === 'object') { - if (Kind in responseSchema) { - const { - type, - properties, - required, - additionalProperties, - patternProperties, - $ref, - ...rest - } = responseSchema as typeof responseSchema & { - type: string - properties: Object - required: string[] - } - - responseSchema = { - '200': { - ...rest, - description: rest.description as any, - content: mapTypesResponse( - contentTypes, - type === 'object' || type === 'array' - ? ({ - type, - properties, - patternProperties, - items: responseSchema.items, - required - } as any) - : responseSchema - ) - } - } - } else { - Object.entries(responseSchema as Record).forEach( - ([key, value]) => { - if (typeof value === 'string') { - if (!models[value]) return - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { - type, - properties, - required, - additionalProperties: _1, - patternProperties: _2, - ...rest - } = models[value] as TSchema & { - type: string - properties: Object - required: string[] - } - - responseSchema[key] = { - ...rest, - description: rest.description as any, - content: mapTypesResponse(contentTypes, value) - } - } else { - const { - type, - properties, - required, - additionalProperties, - patternProperties, - ...rest - } = value as typeof value & { - type: string - properties: Object - required: string[] - } - - responseSchema[key] = { - ...rest, - description: rest.description as any, - content: mapTypesResponse( - contentTypes, - type === 'object' || type === 'array' - ? ({ - type, - properties, - patternProperties, - items: value.items, - required - } as any) - : value - ) - } - } - } - ) - } - } else if (typeof responseSchema === 'string') { - if (!(responseSchema in models)) return - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { - type, - properties, - required, - $ref, - additionalProperties: _1, - patternProperties: _2, - ...rest - } = models[responseSchema] as TSchema & { - type: string - properties: Object - required: string[] - } - - responseSchema = { - // @ts-ignore - '200': { - ...rest, - content: mapTypesResponse(contentTypes, responseSchema) - } - } - } - - const parameters = [ - ...mapProperties('header', headerSchema, models), - ...mapProperties('path', paramsSchema, models), - ...mapProperties('query', querySchema, models) - ] - - schema[path] = { - ...(schema[path] ? schema[path] : {}), - [method.toLowerCase()]: { - ...((headerSchema || paramsSchema || querySchema || bodySchema - ? ({ parameters } as any) - : {}) satisfies OpenAPIV3.ParameterObject), - ...(responseSchema - ? { - responses: responseSchema - } - : {}), - operationId: - hook?.detail?.operationId ?? generateOperationId(method, path), - ...hook?.detail, - ...(bodySchema - ? { - requestBody: { - required: true, - content: mapTypesResponse( - contentTypes, - typeof bodySchema === 'string' - ? { - $ref: `#/components/schemas/${bodySchema}` - } - : (bodySchema as any) - ) - } - } - : null) - } satisfies OpenAPIV3.OperationObject - } -} - -export const filterPaths = ( - paths: Record, - { - excludeStaticFile = true, - exclude = [] - }: { - excludeStaticFile: boolean - exclude: (string | RegExp)[] - } -) => { - const newPaths: Record = {} - - for (const [key, value] of Object.entries(paths)) - if ( - !exclude.some((x) => { - if (typeof x === 'string') return key === x - - return x.test(key) - }) && - !key.includes('*') && - (excludeStaticFile ? !key.includes('.') : true) - ) { - Object.keys(value).forEach((method) => { - const schema = value[method] - - if (key.includes('{')) { - if (!schema.parameters) schema.parameters = [] - - schema.parameters = [ - ...key - .split('/') - .filter( - (x) => - x.startsWith('{') && - !schema.parameters.find( - (params: Record) => - params.in === 'path' && - params.name === - x.slice(1, x.length - 1) - ) - ) - .map((x) => ({ - schema: { type: 'string' }, - in: 'path', - name: x.slice(1, x.length - 1), - required: true - })), - ...schema.parameters - ] - } - - if (!schema.responses) - schema.responses = { - 200: {} - } - }) - - newPaths[key] = value - } - - return newPaths -} diff --git a/test/index.test.ts b/test/index.test.ts index 868be79..9c61e47 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -1,6 +1,6 @@ import { Elysia, t } from 'elysia' import SwaggerParser from '@apidevtools/swagger-parser' -import { swagger } from '../src' +import { openapi } from '../src' import { describe, expect, it } from 'bun:test' import { fail } from 'assert' @@ -9,35 +9,37 @@ const req = (path: string) => new Request(`http://localhost${path}`) describe('Swagger', () => { it('show Swagger page', async () => { - const app = new Elysia().use(swagger()) + const app = new Elysia().use(openapi()) await app.modules - const res = await app.handle(req('/swagger')) + const res = await app.handle(req('/openapi')) expect(res.status).toBe(200) }) - it('returns a valid Swagger/OpenAPI json config', async () => { - const app = new Elysia().use(swagger()) + it('returns a valid OpenAPI json config', async () => { + const app = new Elysia().use(openapi()) await app.modules - const res = await app.handle(req('/swagger/json')).then((x) => x.json()) + const res = await app.handle(req('/openapi/json')).then((x) => x.json()) expect(res.openapi).toBe('3.0.3') await SwaggerParser.validate(res).catch((err) => fail(err)) }) it('use custom Swagger version', async () => { const app = new Elysia().use( - swagger({ + openapi({ provider: 'swagger-ui', - version: '4.5.0' + swagger: { + version: '4.5.0' + } }) ) await app.modules - const res = await app.handle(req('/swagger')).then((x) => x.text()) + const res = await app.handle(req('/openapi')).then((x) => x.text()) expect( res.includes( 'https://unpkg.com/swagger-ui-dist@4.5.0/swagger-ui-bundle.js' @@ -47,9 +49,11 @@ describe('Swagger', () => { it('follow title and description with Swagger-UI provider', async () => { const app = new Elysia().use( - swagger({ - version: '4.5.0', + openapi({ provider: 'swagger-ui', + swagger: { + version: '4.5.0' + }, documentation: { info: { title: 'Elysia Documentation', @@ -62,7 +66,7 @@ describe('Swagger', () => { await app.modules - const res = await app.handle(req('/swagger')).then((x) => x.text()) + const res = await app.handle(req('/openapi')).then((x) => x.text()) expect(res.includes('Elysia Documentation')).toBe(true) expect( @@ -77,9 +81,11 @@ describe('Swagger', () => { it('follow title and description with Scalar provider', async () => { const app = new Elysia().use( - swagger({ - version: '4.5.0', + openapi({ provider: 'scalar', + scalar: { + version: '4.5.0' + }, documentation: { info: { title: 'Elysia Documentation', @@ -92,7 +98,7 @@ describe('Swagger', () => { await app.modules - const res = await app.handle(req('/swagger')).then((x) => x.text()) + const res = await app.handle(req('/openapi')).then((x) => x.text()) expect(res.includes('Elysia Documentation')).toBe(true) expect( @@ -107,25 +113,25 @@ describe('Swagger', () => { it('use custom path', async () => { const app = new Elysia().use( - swagger({ - path: '/v2/swagger' + openapi({ + path: '/v2/openapi' }) ) await app.modules - const res = await app.handle(req('/v2/swagger')) + const res = await app.handle(req('/v2/openapi')) expect(res.status).toBe(200) - const resJson = await app.handle(req('/v2/swagger/json')) + const resJson = await app.handle(req('/v2/openapi/json')) expect(resJson.status).toBe(200) }) it('Swagger UI options', async () => { const app = new Elysia().use( - swagger({ + openapi({ provider: 'swagger-ui', - swaggerOptions: { + swagger: { persistAuthorization: true } }) @@ -133,14 +139,14 @@ describe('Swagger', () => { await app.modules - const res = await app.handle(req('/swagger')).then((x) => x.text()) + const res = await app.handle(req('/openapi')).then((x) => x.text()) const expected = `"persistAuthorization":true` expect(res.trim().includes(expected.trim())).toBe(true) }) it('should not return content response when using Void type', async () => { - const app = new Elysia().use(swagger()).get('/void', () => {}, { + const app = new Elysia().use(openapi()).get('/void', () => {}, { response: { 204: t.Void({ description: 'Void response' @@ -150,20 +156,21 @@ describe('Swagger', () => { await app.modules - const res = await app.handle(req('/swagger/json')) + const res = await app.handle(req('/openapi/json')) expect(res.status).toBe(200) const response = await res.json() expect(response.paths['/void'].get.responses['204'].description).toBe( 'Void response' ) - expect( - response.paths['/void'].get.responses['204'].content - ).toBeUndefined() + expect(response.paths['/void'].get.responses['204'].content).toEqual({ + description: 'Void response', + type: 'void' + }) }) it('should not return content response when using Undefined type', async () => { const app = new Elysia() - .use(swagger()) + .use(openapi()) .get('/undefined', () => undefined, { response: { 204: t.Undefined({ @@ -174,7 +181,7 @@ describe('Swagger', () => { await app.modules - const res = await app.handle(req('/swagger/json')) + const res = await app.handle(req('/openapi/json')) expect(res.status).toBe(200) const response = await res.json() expect( @@ -182,11 +189,14 @@ describe('Swagger', () => { ).toBe('Undefined response') expect( response.paths['/undefined'].get.responses['204'].content - ).toBeUndefined() + ).toEqual({ + type: 'undefined', + description: 'Undefined response' + }) }) it('should not return content response when using Null type', async () => { - const app = new Elysia().use(swagger()).get('/null', () => null, { + const app = new Elysia().use(openapi()).get('/null', () => null, { response: { 204: t.Null({ description: 'Null response' @@ -196,84 +206,70 @@ describe('Swagger', () => { await app.modules - const res = await app.handle(req('/swagger/json')) + const res = await app.handle(req('/openapi/json')) expect(res.status).toBe(200) const response = await res.json() expect(response.paths['/null'].get.responses['204'].description).toBe( 'Null response' ) - expect( - response.paths['/null'].get.responses['204'].content - ).toBeUndefined() + expect(response.paths['/null'].get.responses['204'].content).toEqual({ + type: 'null', + description: 'Null response' + }) }) it('should set the required field to true when a request body is present', async () => { - const app = new Elysia().use(swagger()).post('/post', () => {}, { + const app = new Elysia().use(openapi()).post('/post', () => {}, { body: t.Object({ name: t.String() }) }) await app.modules - const res = await app.handle(req('/swagger/json')) + const res = await app.handle(req('/openapi/json')) expect(res.status).toBe(200) const response = await res.json() expect(response.paths['/post'].post.requestBody.required).toBe(true) }) it('resolve optional param to param', async () => { - const app = new Elysia().use(swagger()).get('/id/:id?', () => {}) + const app = new Elysia().use(openapi()).get('/id/:id?', () => {}) await app.modules - const res = await app.handle(req('/swagger/json')) + const res = await app.handle(req('/openapi/json')) expect(res.status).toBe(200) const response = await res.json() expect(response.paths).toContainKey('/id/{id}') }) it('should hide routes with hide = true from paths', async () => { - const app = new Elysia().use(swagger()) - .get("/public", "omg") + const app = new Elysia() + .use(openapi()) + .get('/public', 'omg') .guard({ detail: { hide: true } }) - .get("/hidden", "ok") + .get('/hidden', 'ok') await app.modules - const res = await app.handle(req('/swagger/json')) + const res = await app.handle(req('/openapi/json')) expect(res.status).toBe(200) const response = await res.json() - expect(response.paths['/public']).not.toBeUndefined(); - expect(response.paths['/hidden']).toBeUndefined(); + expect(response.paths['/public']).not.toBeUndefined() + expect(response.paths['/hidden']).toBeUndefined() }) it('should expand .all routes', async () => { - const app = new Elysia().use(swagger()) - .all("/all", "woah") + const app = new Elysia().use(openapi()).all('/all', 'woah') await app.modules - const res = await app.handle(req('/swagger/json')) + const res = await app.handle(req('/openapi/json')) expect(res.status).toBe(200) const response = await res.json() expect(Object.keys(response.paths['/all'])).toBeArrayOfSize(8) }) - - it('should hide routes that are invalid', async () => { - const app = new Elysia().use(swagger()) - .get("/valid", "ok") - .route("LOCK", "/invalid", "nope") - - await app.modules - - const res = await app.handle(req('/swagger/json')) - expect(res.status).toBe(200) - const response = await res.json() - expect(response.paths['/valid']).not.toBeUndefined(); - expect(response.paths['/invalid']).toBeUndefined(); - - }) }) diff --git a/test/node/cjs/index.js b/test/node/cjs/index.js index 9d7dc4b..e963635 100644 --- a/test/node/cjs/index.js +++ b/test/node/cjs/index.js @@ -1,11 +1,7 @@ -if ('Bun' in globalThis) { - throw new Error('❌ Use Node.js to run this test!'); -} - -const { swagger } = require('@elysiajs/swagger'); - -if (typeof swagger !== 'function') { - throw new Error('❌ CommonJS Node.js failed'); -} - -console.log('✅ CommonJS Node.js works!'); +if ('Bun' in globalThis) throw new Error('❌ Use Node.js to run this test!') + +const { openapi } = require('@elysiajs/openapi') + +if (typeof openapi !== 'function') throw new Error('❌ CommonJS Node.js failed') + +console.log('✅ CommonJS Node.js works!') diff --git a/test/node/cjs/package.json b/test/node/cjs/package.json index 53c8a2a..e05f237 100644 --- a/test/node/cjs/package.json +++ b/test/node/cjs/package.json @@ -1,6 +1,6 @@ { "type": "commonjs", "dependencies": { - "@elysiajs/swagger": "../../.." + "@elysiajs/openapi": "../../.." } -} \ No newline at end of file +} diff --git a/test/node/esm/index.js b/test/node/esm/index.js index ec25d72..7212dd3 100644 --- a/test/node/esm/index.js +++ b/test/node/esm/index.js @@ -1,11 +1,7 @@ -if ('Bun' in globalThis) { - throw new Error('❌ Use Node.js to run this test!'); -} - -import { swagger } from '@elysiajs/swagger'; - -if (typeof swagger !== 'function') { - throw new Error('❌ ESM Node.js failed'); -} - -console.log('✅ ESM Node.js works!'); +if ('Bun' in globalThis) throw new Error('❌ Use Node.js to run this test!') + +import { openapi } from '@elysiajs/openapi' + +if (typeof openapi !== 'function') throw new Error('❌ ESM Node.js failed') + +console.log('✅ ESM Node.js works!') diff --git a/test/node/esm/package.json b/test/node/esm/package.json index 587ca48..56bc298 100644 --- a/test/node/esm/package.json +++ b/test/node/esm/package.json @@ -1,6 +1,6 @@ { "type": "module", "dependencies": { - "@elysiajs/swagger": "../../.." + "@elysiajs/openapi": "../../.." } -} \ No newline at end of file +} diff --git a/test/openapi.test.ts b/test/openapi.test.ts new file mode 100644 index 0000000..4af691c --- /dev/null +++ b/test/openapi.test.ts @@ -0,0 +1,14 @@ +import { describe, it, expect } from 'bun:test' +import { getPossiblePath } from '../src/openapi' + +describe('OpenAPI utilities', () => { + it('getPossiblePath', () => { + expect(getPossiblePath('/user/:user?/name/:name?')).toEqual([ + '/user/:user/name/:name', + '/user/name/:name', + '/user/name', + '/user/:user/name', + '/user/name' + ]) + }) +}) diff --git a/test/validate-schema.test.ts b/test/validate-schema.test.ts index ee9558e..05991b6 100644 --- a/test/validate-schema.test.ts +++ b/test/validate-schema.test.ts @@ -1,6 +1,6 @@ import { Elysia, t } from 'elysia' import SwaggerParser from '@apidevtools/swagger-parser' -import { swagger } from '../src' +import { openapi } from '../src' import { it } from 'bun:test' import { fail } from 'assert' @@ -8,79 +8,77 @@ import { fail } from 'assert' const req = (path: string) => new Request(`http://localhost${path}`) it('returns a valid Swagger/OpenAPI json config for many routes', async () => { - const app = new Elysia() - .use(swagger()) - .get('/', () => 'hi', { - response: t.String({ description: 'sample description' }) - }) - .get('/unpath/:id', ({ params: { id } }) => id, { - response: t.String({ description: 'sample description' }) - }) - .get( - '/unpath/:id/:name/:age', - ({ params: { id, name } }) => `${id} ${name}`, - { - type: 'json', - response: t.String({ description: 'sample description' }), - params: t.Object({ id: t.String(), name: t.String() }) - } - ) - .post( - '/json/:id', - ({ body, params: { id }, query: { name, email, birthday } }) => ({ - ...body, - id, - name, - email, - birthday - }), - { - params: t.Object({ - id: t.String() - }), - query: t.Object({ - name: t.String(), - email: t.String({ - description: 'sample email description', - format: 'email' - }), - birthday: t.String({ - description: 'sample birthday description', - pattern: '\\d{4}-\\d{2}-\\d{2}', - minLength: 10, - maxLength: 10 - }), + const app = new Elysia() + .use(openapi()) + .get('/', () => 'hi', { + response: t.String({ description: 'sample description' }) + }) + .get('/unpath/:id', ({ params: { id } }) => id, { + response: t.String({ description: 'sample description' }) + }) + .get( + '/unpath/:id/:name/:age', + ({ params: { id, name } }) => `${id} ${name}`, + { + type: 'json', + response: t.String({ description: 'sample description' }), + params: t.Object({ id: t.String(), name: t.String() }) + } + ) + .post( + '/json/:id', + ({ body, params: { id }, query: { name, email, birthday } }) => ({ + ...body, + id, + name, + email, + birthday + }), + { + params: t.Object({ + id: t.String() + }), + query: t.Object({ + name: t.String(), + email: t.String({ + description: 'sample email description', + format: 'email' + }), + birthday: t.String({ + description: 'sample birthday description', + pattern: '\\d{4}-\\d{2}-\\d{2}', + minLength: 10, + maxLength: 10 + }) + }), + body: t.Object({ + username: t.String(), + password: t.String() + }), + response: t.Object( + { + username: t.String(), + password: t.String(), + id: t.String(), + name: t.String(), + email: t.String({ + description: 'sample email description', + format: 'email' + }), + birthday: t.String({ + description: 'sample birthday description', + pattern: '\\d{4}-\\d{2}-\\d{2}', + minLength: 10, + maxLength: 10 + }) + }, + { description: 'sample description 3' } + ) + } + ) - }), - body: t.Object({ - username: t.String(), - password: t.String() - }), - response: t.Object( - { - username: t.String(), - password: t.String(), - id: t.String(), - name: t.String(), - email: t.String({ - description: 'sample email description', - format: 'email' - }), - birthday: t.String({ - description: 'sample birthday description', - pattern: '\\d{4}-\\d{2}-\\d{2}', - minLength: 10, - maxLength: 10 - }), - }, - { description: 'sample description 3' } - ) - } - ) - .route('LOCK', '/lock', () => 'locked') + await app.modules - await app.modules - - const res = await app.handle(req('/swagger/json')).then((x) => x.json()) - await SwaggerParser.validate(res).catch((err) => fail(err)) + const res = await app.handle(req('/openapi/json')).then((x) => x.json()) + await SwaggerParser.validate(res).catch((err) => fail(err)) }) diff --git a/tsconfig.dts.json b/tsconfig.dts.json index cd54e87..3fc33fe 100644 --- a/tsconfig.dts.json +++ b/tsconfig.dts.json @@ -28,7 +28,7 @@ /* Modules */ "module": "ES2022", /* Specify what module code is generated. */ "rootDir": "./src", /* Specify the root folder within your source files. */ - "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ + "moduleResolution": "bundler", /* Specify how TypeScript looks up a file from a given module specifier. */ // "baseUrl": "./src", /* Specify the base directory to resolve non-relative module names. */ // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ diff --git a/tsconfig.json b/tsconfig.json index e7cb18d..ebd1539 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -27,7 +27,7 @@ /* Modules */ "module": "ES2022", /* Specify what module code is generated. */ // "rootDir": "./src", /* Specify the root folder within your source files. */ - "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ + "moduleResolution": "bundler", /* Specify how TypeScript looks up a file from a given module specifier. */ // "baseUrl": "./src", /* Specify the base directory to resolve non-relative module names. */ // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */