diff --git a/.npmignore b/.npmignore index 2f21828..38fa1bc 100644 --- a/.npmignore +++ b/.npmignore @@ -19,4 +19,7 @@ CHANGELOG.md .eslintrc.js tsconfig.cjs.json tsconfig.esm.json +tsconfig.dts.json +src +build.ts diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..de30132 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,7 @@ +{ + "useTabs": true, + "tabWidth": 4, + "semi": false, + "singleQuote": true, + "trailingComma": "none" +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 3202445..3d53b2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,9 @@ +# 1.1.0-rc.0 - 12 Jul 2024 +Change: +- Add support for Elysia 1.1 + + # 1.0.2 - 18 Mar 2024 Change: - Add support for Elysia 1.0 diff --git a/build.ts b/build.ts new file mode 100644 index 0000000..e1305dc --- /dev/null +++ b/build.ts @@ -0,0 +1,37 @@ +import { $ } from 'bun' +import { build, type Options } from 'tsup' + +await $`rm -rf dist` + +const tsupConfig: Options = { + 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 + }) +]) + +await $`tsc --project tsconfig.dts.json` + +await Promise.all([$`cp dist/*.d.ts dist/cjs`]) + +process.exit() diff --git a/bun.lockb b/bun.lockb index 9d75515..e6f5bf8 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/example/index.ts b/example/index.ts index 6072150..bcb386f 100644 --- a/example/index.ts +++ b/example/index.ts @@ -39,7 +39,6 @@ const app = new Elysia() } }) ) - .use(plugin) + // .use(plugin) + .get('/id/:id?', 'a') .listen(3000) - -console.log(app.routes) diff --git a/package.json b/package.json index da9b7e6..bf8f981 100644 --- a/package.json +++ b/package.json @@ -1,55 +1,81 @@ { - "name": "@elysiajs/swagger", - "version": "1.0.4", - "description": "Plugin for Elysia to auto-generate Swagger page", - "author": { - "name": "saltyAom", - "url": "https://github.com/SaltyAom", - "email": "saltyaom@gmail.com" - }, - "main": "./dist/index.js", - "exports": { - "bun": "./dist/index.js", - "node": "./dist/cjs/index.js", - "require": "./dist/cjs/index.js", - "import": "./dist/index.js", - "default": "./dist/index.js" - }, - "types": "./dist/index.d.ts", - "keywords": [ - "elysia", - "swagger" - ], - "homepage": "https://github.com/elysiajs/elysia-swagger", - "repository": { - "type": "git", - "url": "https://github.com/elysiajs/elysia-swagger" - }, - "bugs": "https://github.com/elysiajs/elysia-swagger/issues", - "license": "MIT", - "scripts": { - "dev": "bun run --watch example/index.ts", - "test": "bun test && npm run test:node", - "test:node": "npm install --prefix ./test/node/cjs/ && npm install --prefix ./test/node/esm/ && node ./test/node/cjs/index.js && node ./test/node/esm/index.js", - "build": "rimraf dist && tsc --project tsconfig.esm.json && tsc --project tsconfig.cjs.json", - "release": "npm run build && npm run test && npm publish --access public" - }, - "peerDependencies": { - "elysia": ">= 1.0.2" - }, - "devDependencies": { - "@apidevtools/swagger-parser": "^10.1.0", - "@scalar/api-reference": "^1.12.5", - "@types/bun": "^1.0.4", - "@types/lodash.clonedeep": "^4.5.7", - "@types/node": "^20.1.4", - "elysia": "1.0.2", - "eslint": "^8.40.0", - "rimraf": "4.3", - "typescript": "^5.0.4" - }, - "dependencies": { - "lodash.clonedeep": "^4.5.0", - "openapi-types": "^12.1.3" - } + "name": "@elysiajs/swagger", + "version": "1.1.0-rc.1", + "description": "Plugin for Elysia to auto-generate Swagger page", + "author": { + "name": "saltyAom", + "url": "https://github.com/SaltyAom", + "email": "saltyaom@gmail.com" + }, + "main": "./dist/cjs/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "exports": { + "./package.json": "./package.json", + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/cjs/index.js" + }, + "./types": { + "types": "./dist/types.d.ts", + "import": "./dist/types.mjs", + "require": "./dist/cjs/types.js" + }, + "./utils": { + "types": "./dist/utils.d.ts", + "import": "./dist/utils.mjs", + "require": "./dist/cjs/utils.js" + }, + "./scalar": { + "types": "./dist/scalar/index.d.ts", + "import": "./dist/scalar/index.mjs", + "require": "./dist/cjs/scalar/index.js" + }, + "./scalar/theme": { + "types": "./dist/scalar/theme.d.ts", + "import": "./dist/scalar/theme.mjs", + "require": "./dist/cjs/scalar/theme.js" + }, + "./scalar/types": { + "types": "./dist/scalar/types/index.d.ts", + "import": "./dist/scalar/types/index.mjs", + "require": "./dist/cjs/scalar/types/index.js" + } + }, + "keywords": [ + "elysia", + "swagger" + ], + "homepage": "https://github.com/elysiajs/elysia-swagger", + "repository": { + "type": "git", + "url": "https://github.com/elysiajs/elysia-swagger" + }, + "bugs": "https://github.com/elysiajs/elysia-swagger/issues", + "license": "MIT", + "scripts": { + "dev": "bun run --watch example/index.ts", + "test": "bun test && npm run test:node", + "test:node": "npm install --prefix ./test/node/cjs/ && npm install --prefix ./test/node/esm/ && node ./test/node/cjs/index.js && node ./test/node/esm/index.js", + "build": "bun build.ts", + "release": "npm run build && npm run test && npm publish --access public" + }, + "peerDependencies": { + "elysia": ">= 1.1.0-rc.2" + }, + "devDependencies": { + "@apidevtools/swagger-parser": "^10.1.0", + "@scalar/api-reference": "^1.12.5", + "@types/bun": "1.1.6", + "@types/lodash.clonedeep": "^4.5.9", + "elysia": ">= 1.1.0-rc.2", + "eslint": "9.6.0", + "tsup": "^8.1.0", + "typescript": "^5.5.3" + }, + "dependencies": { + "lodash.clonedeep": "^4.5.0", + "openapi-types": "^12.1.3" + } } diff --git a/src/index.ts b/src/index.ts index a247e45..3ff0bd5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,171 +1,165 @@ /* eslint-disable @typescript-eslint/ban-ts-comment */ -import { type Elysia, type InternalRoute } from 'elysia' +import { Elysia, type InternalRoute } from "elysia"; -import { SwaggerUIRender } from './swagger' -import { ScalarRender } from './scalar' +import { SwaggerUIRender } from "./swagger"; +import { ScalarRender } from "./scalar"; -import { filterPaths, registerSchemaPath } from './utils' +import { filterPaths, registerSchemaPath } from "./utils"; -import type { OpenAPIV3 } from 'openapi-types' -import type { ReferenceConfiguration } from './scalar/types' -import type { ElysiaSwaggerConfig } from './types' +import type { OpenAPIV3 } from "openapi-types"; +import type { ReferenceConfiguration } from "./scalar/types"; +import type { ElysiaSwaggerConfig } from "./types"; /** * Plugin for [elysia](https://github.com/elysiajs/elysia) that auto-generate Swagger 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, - exclude = [], - swaggerOptions = {}, - theme = `https://unpkg.com/swagger-ui-dist@${version}/swagger-ui.css`, - autoDarkMode = true, - excludeMethods = ['OPTIONS'], - excludeTags = [] - }: ElysiaSwaggerConfig = { - provider: 'scalar', - scalarVersion: 'latest', - scalarCDN: '', - scalarConfig: {}, - documentation: {}, - version: '5.9.0', - excludeStaticFile: true, - path: '/swagger' as Path, - exclude: [], - swaggerOptions: {}, - autoDarkMode: true, - excludeMethods: ['OPTIONS'], - excludeTags: [] - } - ) => - (app: Elysia) => { - const schema = {} - let totalRoutes = 0 +export const swagger = async ( + { + provider = "scalar", + scalarVersion = "latest", + scalarCDN = "", + scalarConfig = {}, + documentation = {}, + version = "5.9.0", + excludeStaticFile = true, + path = "/swagger" as Path, + exclude = [], + swaggerOptions = {}, + theme = `https://unpkg.com/swagger-ui-dist@${version}/swagger-ui.css`, + autoDarkMode = true, + excludeMethods = ["OPTIONS"], + excludeTags = [], + }: ElysiaSwaggerConfig = { + provider: "scalar", + scalarVersion: "latest", + scalarCDN: "", + scalarConfig: {}, + documentation: {}, + version: "5.9.0", + excludeStaticFile: true, + path: "/swagger" as Path, + exclude: [], + swaggerOptions: {}, + autoDarkMode: true, + excludeMethods: ["OPTIONS"], + excludeTags: [], + }, +) => { + const schema = {}; + let totalRoutes = 0; - if (!version) - version = `https://unpkg.com/swagger-ui-dist@${version}/swagger-ui.css` + if (!version) + version = `https://unpkg.com/swagger-ui-dist@${version}/swagger-ui.css`; - const info = { - title: 'Elysia Documentation', - description: 'Development documentation', - version: '0.0.0', - ...documentation.info - } + const info = { + title: "Elysia Documentation", + description: "Development documentation", + version: "0.0.0", + ...documentation.info, + }; - const relativePath = path.startsWith('/') ? path.slice(1) : path + const relativePath = path.startsWith("/") ? path.slice(1) : path; - app.get( - path, - () => { - const combinedSwaggerOptions = { - url: `${relativePath}/json`, - dom_id: '#swagger-ui', - ...swaggerOptions - } + const app = new Elysia({ name: "@elysiajs/swagger" }); - const stringifiedSwaggerOptions = JSON.stringify( - combinedSwaggerOptions, - (key, value) => { - if (typeof value == 'function') return undefined + app.get(path, function documentation() { + const combinedSwaggerOptions = { + url: `${relativePath}/json`, + dom_id: "#swagger-ui", + ...swaggerOptions, + }; - return value - } - ) + const stringifiedSwaggerOptions = JSON.stringify( + combinedSwaggerOptions, + (key, value) => { + if (typeof value == "function") return undefined; - const scalarConfiguration: ReferenceConfiguration = { - spec: { - ...scalarConfig.spec, - url: `${relativePath}/json`, - }, - ...scalarConfig - } + return value; + }, + ); - return new Response( - provider === 'swagger-ui' - ? SwaggerUIRender( - info, - version, - theme, - stringifiedSwaggerOptions, - autoDarkMode - ) - : ScalarRender( - scalarVersion, - scalarConfiguration, - scalarCDN - ), - { - headers: { - 'content-type': 'text/html; charset=utf8' - } - } - ) - } - ).get( - path === '/' ? '/json' : `${path}/json`, - () => { - const routes = app.routes as InternalRoute[] + const scalarConfiguration: ReferenceConfiguration = { + spec: { + ...scalarConfig.spec, + url: `${relativePath}/json`, + }, + ...scalarConfig, + }; - if (routes.length !== totalRoutes) { - totalRoutes = routes.length + return new Response( + provider === "swagger-ui" + ? SwaggerUIRender( + info, + version, + theme, + stringifiedSwaggerOptions, + autoDarkMode, + ) + : ScalarRender(scalarVersion, scalarConfiguration, scalarCDN), + { + headers: { + "content-type": "text/html; charset=utf8", + }, + }, + ); + }).get(path === "/" ? "/json" : `${path}/json`, function openAPISchema() { + // @ts-expect-error Private property + const routes = app.getGlobalRoutes() as InternalRoute[]; - routes.forEach((route: InternalRoute) => { - if (excludeMethods.includes(route.method)) return + if (routes.length !== totalRoutes) { + totalRoutes = routes.length; - registerSchemaPath({ - schema, - hook: route.hooks, - method: route.method, - path: route.path, - // @ts-ignore - models: app.definitions?.type, - contentType: route.hooks.type - }) - }) - } + routes.forEach((route: InternalRoute) => { + if (excludeMethods.includes(route.method)) return; - return { - openapi: '3.0.3', - ...{ - ...documentation, - tags: documentation.tags?.filter((tag) => !excludeTags?.includes(tag?.name)), - info: { - title: 'Elysia Documentation', - description: 'Development documentation', - version: '0.0.0', - ...documentation.info - } - }, - paths: {...filterPaths(schema, { - excludeStaticFile, - exclude: Array.isArray(exclude) ? exclude : [exclude] - }), - ...documentation.paths - }, - components: { - ...documentation.components, - schemas: { - // @ts-ignore - ...app.definitions?.type, - ...documentation.components?.schemas - } - } - } satisfies OpenAPIV3.Document - } - ) + registerSchemaPath({ + schema, + hook: route.hooks, + method: route.method, + path: route.path, + // @ts-ignore + models: app.definitions?.type, + contentType: route.hooks.type, + }); + }); + } - // This is intentional to prevent deeply nested type - return app - } + return { + openapi: "3.0.3", + ...{ + ...documentation, + tags: documentation.tags?.filter( + (tag) => !excludeTags?.includes(tag?.name), + ), + info: { + title: "Elysia Documentation", + description: "Development documentation", + version: "0.0.0", + ...documentation.info, + }, + }, + paths: { + ...filterPaths(schema, { + excludeStaticFile, + exclude: Array.isArray(exclude) ? exclude : [exclude], + }), + ...documentation.paths, + }, + components: { + ...documentation.components, + schemas: { + // @ts-ignore + ...app.definitions?.type, + ...documentation.components?.schemas, + }, + }, + } satisfies OpenAPIV3.Document; + }); -export default swagger + // This is intentional to prevent deeply nested type + return app; +}; + +export default swagger; diff --git a/src/utils.ts b/src/utils.ts index bd2ed5a..a52ea84 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -8,338 +8,352 @@ import type { OpenAPIV3 } from 'openapi-types' import deepClone from 'lodash.clonedeep' export const toOpenAPIPath = (path: string) => - path - .split('/') - .map((x) => (x.startsWith(':') ? `{${x.slice(1, x.length)}}` : x)) - .join('/') + 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 + name: string, + schema: TSchema | string | undefined, + models: Record ) => { - if (schema === undefined) return [] + if (schema === undefined) return [] - if (typeof schema === 'string') - if (schema in models) schema = models[schema] - else throw new Error(`Can't find model ${schema}`) + 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 - } - }) + 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[] - } + types: string[], + schema: + | string + | { + type: string + properties: Object + required: string[] + } ) => { - if ( - typeof schema === 'object' && - ['void', 'undefined', 'null'].includes(schema.type) - ) - return + if ( + typeof schema === 'object' && + ['void', 'undefined', 'null'].includes(schema.type) + ) + return - const responses: Record = {} + const responses: Record = {} - for (const type of types) - responses[type] = { - schema: - typeof schema === 'string' - ? { - $ref: `#/components/schemas/${schema}` - } - : { ...(schema as any) } - } + for (const type of types) + responses[type] = { + schema: + typeof schema === 'string' + ? { + $ref: `#/components/schemas/${schema}` + } + : { ...(schema as any) } + } - return responses + return responses } export const capitalize = (word: string) => - word.charAt(0).toUpperCase() + word.slice(1) + word.charAt(0).toUpperCase() + word.slice(1) export const generateOperationId = (method: string, paths: string) => { - let operationId = method.toLowerCase() + let operationId = method.toLowerCase() - if (paths === '/') return operationId + 'Index' + 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) - } - } + for (const path of paths.split('/')) { + if (path.charCodeAt(0) === 123) { + operationId += 'By' + capitalize(path.slice(1, -1)) + } else { + operationId += capitalize(path) + } + } - return operationId + return operationId } export const registerSchemaPath = ({ - schema, - path, - method, - hook, - models + schema, + path, + method, + hook, + models }: { - schema: Partial - contentType?: string | string[] - path: string - method: HTTPMethod - hook?: LocalHook - models: Record + schema: Partial + contentType?: string | string[] + path: string + method: HTTPMethod + hook?: LocalHook + models: Record }) => { - if (hook) hook = deepClone(hook) + if (hook) hook = deepClone(hook) - const contentType = hook?.type ?? [ - 'application/json', - 'multipart/form-data', - 'text/plain' - ] + const contentType = hook?.type ?? [ + 'application/json', + 'multipart/form-data', + 'text/plain' + ] - path = toOpenAPIPath(path) + path = toOpenAPIPath(path) - const contentTypes = - typeof contentType === 'string' - ? [contentType] - : contentType ?? ['application/json'] + const contentTypes = + typeof contentType === 'string' + ? [contentType] + : contentType ?? ['application/json'] - const bodySchema = hook?.body - const paramsSchema = hook?.params - const headerSchema = hook?.headers - const querySchema = hook?.query - let responseSchema = hook?.response as unknown as OpenAPIV3.ResponsesObject + const bodySchema = hook?.body + const paramsSchema = hook?.params + const headerSchema = hook?.headers + const querySchema = hook?.query + let responseSchema = hook?.response as unknown as OpenAPIV3.ResponsesObject - if (typeof responseSchema === 'object') { - if (Kind in responseSchema) { - const { - type, - properties, - required, - additionalProperties, - patternProperties, - ...rest - } = responseSchema as typeof responseSchema & { - type: string - properties: Object - required: string[] - } + if (typeof responseSchema === 'object') { + if (Kind in responseSchema) { + const { + type, + properties, + required, + additionalProperties, + patternProperties, + ...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 + 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[] - } + // 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, 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 + 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, - additionalProperties: _1, - patternProperties: _2, - ...rest - } = models[responseSchema] as TSchema & { - type: string - properties: Object - required: string[] - } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { + type, + properties, + required, + additionalProperties: _1, + patternProperties: _2, + ...rest + } = models[responseSchema] as TSchema & { + type: string + properties: Object + required: string[] + } - responseSchema = { - // @ts-ignore - '200': { - ...rest, - content: mapTypesResponse(contentTypes, responseSchema) - } - } - } + responseSchema = { + // @ts-ignore + '200': { + ...rest, + content: mapTypesResponse(contentTypes, responseSchema) + } + } + } - const parameters = [ - ...mapProperties('header', headerSchema, models), - ...mapProperties('path', paramsSchema, models), - ...mapProperties('query', querySchema, models) - ] + 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 - } + 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)[] - } + paths: Record, + { + excludeStaticFile = true, + exclude = [] + }: { + excludeStaticFile: boolean + exclude: (string | RegExp)[] + } ) => { - const newPaths: Record = {} + const newPaths: Record = {} - for (const [key, value] of Object.entries(paths)) - if ( - !exclude.some((x) => { - if (typeof x === 'string') return key === x + 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('/swagger') && - !key.includes('*') && - (excludeStaticFile ? !key.includes('.') : true) - ) { - Object.keys(value).forEach((method) => { - const schema = value[method] + return x.test(key) + }) && + !key.includes('/swagger') && + !key.includes('*') && + (excludeStaticFile ? !key.includes('.') : true) + ) { + Object.keys(value).forEach((method) => { + const schema = value[method] - if (key.includes('{')) { - if (!schema.parameters) schema.parameters = [] + 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 - ] - } + 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: {} - } - }) + if (!schema.responses) + schema.responses = { + 200: {} + } + }) - newPaths[key] = value - } + newPaths[key] = value + } - return newPaths + return newPaths } diff --git a/test/index.test.ts b/test/index.test.ts index d5e32ba..b84c549 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -8,164 +8,196 @@ import { fail } from 'assert' const req = (path: string) => new Request(`http://localhost${path}`) describe('Swagger', () => { - it('show Swagger page', async () => { - const app = new Elysia().use(swagger()) + it('show Swagger page', async () => { + const app = new Elysia().use(swagger()) - const res = await app.handle(req('/swagger')) - expect(res.status).toBe(200) - }) + await app.modules - it('returns a valid Swagger/OpenAPI json config', async () => { - const app = new Elysia().use(swagger()) - const res = await app.handle(req('/swagger/json')).then((x) => x.json()) - expect(res.openapi).toBe('3.0.3') - await SwaggerParser.validate(res).catch((err) => fail(err)) - }) + const res = await app.handle(req('/swagger')) + expect(res.status).toBe(200) + }) - it('use custom Swagger version', async () => { - const app = new Elysia().use( - swagger({ - provider: 'swagger-ui', - version: '4.5.0' - }) - ) + it('returns a valid Swagger/OpenAPI json config', async () => { + const app = new Elysia().use(swagger()) - const res = await app.handle(req('/swagger')).then((x) => x.text()) - expect( - res.includes( - 'https://unpkg.com/swagger-ui-dist@4.5.0/swagger-ui-bundle.js' - ) - ).toBe(true) - }) + await app.modules - it('follow title and description', async () => { - const app = new Elysia().use( - swagger({ - version: '4.5.0', - provider: 'swagger-ui', - documentation: { - info: { - title: 'Elysia Documentation', - description: 'Herrscher of Human', - version: '1.0.0' - } - } - }) - ) + const res = await app.handle(req('/swagger/json')).then((x) => x.json()) + expect(res.openapi).toBe('3.0.3') + await SwaggerParser.validate(res).catch((err) => fail(err)) + }) - const res = await app.handle(req('/swagger')).then((x) => x.text()) + it('use custom Swagger version', async () => { + const app = new Elysia().use( + swagger({ + provider: 'swagger-ui', + version: '4.5.0' + }) + ) - expect(res.includes('Elysia Documentation')).toBe(true) - expect( - res.includes( - ` x.text()) + expect( + res.includes( + 'https://unpkg.com/swagger-ui-dist@4.5.0/swagger-ui-bundle.js' + ) + ).toBe(true) + }) + + it('follow title and description', async () => { + const app = new Elysia().use( + swagger({ + version: '4.5.0', + provider: 'swagger-ui', + documentation: { + info: { + title: 'Elysia Documentation', + description: 'Herrscher of Human', + version: '1.0.0' + } + } + }) + ) + + await app.modules + + const res = await app.handle(req('/swagger')).then((x) => x.text()) + + expect(res.includes('Elysia Documentation')).toBe(true) + expect( + res.includes( + `` - ) - ).toBe(true) - }) + ) + ).toBe(true) + }) - it('use custom path', async () => { - const app = new Elysia().use( - swagger({ - path: '/v2/swagger' - }) - ) + it('use custom path', async () => { + const app = new Elysia().use( + swagger({ + path: '/v2/swagger' + }) + ) - const res = await app.handle(req('/v2/swagger')) - expect(res.status).toBe(200) + await app.modules - const resJson = await app.handle(req('/v2/swagger/json')) - expect(resJson.status).toBe(200) - }) + const res = await app.handle(req('/v2/swagger')) + expect(res.status).toBe(200) - it('Swagger UI options', async () => { - const app = new Elysia().use( - swagger({ - provider: 'swagger-ui', - swaggerOptions: { - persistAuthorization: true - } - }) - ) - const res = await app.handle(req('/swagger')).then((x) => x.text()) - const expected = `"persistAuthorization":true` + const resJson = await app.handle(req('/v2/swagger/json')) + expect(resJson.status).toBe(200) + }) - expect(res.trim().includes(expected.trim())).toBe(true) - }) + it('Swagger UI options', async () => { + const app = new Elysia().use( + swagger({ + provider: 'swagger-ui', + swaggerOptions: { + persistAuthorization: true + } + }) + ) - it('should not return content response when using Void type', async () => { - const app = new Elysia().use(swagger()).get('/void', () => {}, { - response: { - 204: t.Void({ - description: 'Void response' - }) - } - }) + await app.modules - const res = await app.handle(req('/swagger/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() - }) + const res = await app.handle(req('/swagger')).then((x) => x.text()) + const expected = `"persistAuthorization":true` - it('should not return content response when using Undefined type', async () => { - const app = new Elysia() - .use(swagger()) - .get('/undefined', () => undefined, { - response: { - 204: t.Undefined({ - description: 'Undefined response' - }) - } - }) + expect(res.trim().includes(expected.trim())).toBe(true) + }) - const res = await app.handle(req('/swagger/json')) - expect(res.status).toBe(200) - const response = await res.json() - expect( - response.paths['/undefined'].get.responses['204'].description - ).toBe('Undefined response') - expect( - response.paths['/undefined'].get.responses['204'].content - ).toBeUndefined() - }) + it('should not return content response when using Void type', async () => { + const app = new Elysia().use(swagger()).get('/void', () => {}, { + response: { + 204: t.Void({ + description: 'Void response' + }) + } + }) - it('should not return content response when using Null type', async () => { - const app = new Elysia().use(swagger()).get('/null', () => null, { - response: { - 204: t.Null({ - description: 'Null response' - }) - } - }) + await app.modules - const res = await app.handle(req('/swagger/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() - }) + const res = await app.handle(req('/swagger/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() + }) - it("should set the required field to true when a request body is present", async () => { - const app = new Elysia().use(swagger()).post("/post", () => {}, { - body: t.Object({ name: t.String() }), - }); + it('should not return content response when using Undefined type', async () => { + const app = new Elysia() + .use(swagger()) + .get('/undefined', () => undefined, { + response: { + 204: t.Undefined({ + description: 'Undefined response' + }) + } + }) - const res = await app.handle(req("/swagger/json")); - expect(res.status).toBe(200); - const response = await res.json(); - expect(response.paths['/post'].post.requestBody.required).toBe(true); - }) + await app.modules + const res = await app.handle(req('/swagger/json')) + expect(res.status).toBe(200) + const response = await res.json() + expect( + response.paths['/undefined'].get.responses['204'].description + ).toBe('Undefined response') + expect( + response.paths['/undefined'].get.responses['204'].content + ).toBeUndefined() + }) + + it('should not return content response when using Null type', async () => { + const app = new Elysia().use(swagger()).get('/null', () => null, { + response: { + 204: t.Null({ + description: 'Null response' + }) + } + }) + + await app.modules + + const res = await app.handle(req('/swagger/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() + }) + + it('should set the required field to true when a request body is present', async () => { + const app = new Elysia().use(swagger()).post('/post', () => {}, { + body: t.Object({ name: t.String() }) + }) + + await app.modules + + const res = await app.handle(req('/swagger/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?', () => {}) + + await app.modules + + const res = await app.handle(req('/swagger/json')) + expect(res.status).toBe(200) + const response = await res.json() + expect(response.paths).toContainKey('/id/{id}') + }) }) diff --git a/test/validateSchema.test.ts b/test/validate-schema.test.ts similarity index 99% rename from test/validateSchema.test.ts rename to test/validate-schema.test.ts index bcc74da..868e63b 100644 --- a/test/validateSchema.test.ts +++ b/test/validate-schema.test.ts @@ -78,6 +78,8 @@ it('returns a valid Swagger/OpenAPI json config for many routes', async () => { } ) + await app.modules + const res = await app.handle(req('/swagger/json')).then((x) => x.json()) await SwaggerParser.validate(res).catch((err) => fail(err)) }) diff --git a/tsconfig.cjs.json b/tsconfig.cjs.json deleted file mode 100644 index a5f1b14..0000000 --- a/tsconfig.cjs.json +++ /dev/null @@ -1,104 +0,0 @@ -{ - "compilerOptions": { - /* Visit https://aka.ms/tsconfig to read more about this file */ - - /* Projects */ - // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ - // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ - // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ - // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ - // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ - // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ - - /* Language and Environment */ - "target": "ES2022", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ - "lib": ["ESNext", "DOM", "ScriptHost"], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ - // "jsx": "preserve", /* Specify what JSX code is generated. */ - // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ - // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ - // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ - // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ - // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ - // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ - // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ - // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ - // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ - - /* Modules */ - "module": "CommonJS", /* 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. */ - "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. */ - // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ - "types": ["bun-types"], /* Specify type package names to be included without being referenced in a source file. */ - // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ - // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ - // "resolveJsonModule": true, /* Enable importing .json files. */ - // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ - - /* JavaScript Support */ - // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ - // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ - // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ - - /* Emit */ - "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ - // "declarationMap": true, /* Create sourcemaps for d.ts files. */ - // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ - // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ - // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ - "outDir": "./dist/cjs", /* Specify an output folder for all emitted files. */ - // "removeComments": true, /* Disable emitting comments. */ - // "noEmit": true, /* Disable emitting files from a compilation. */ - // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ - // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ - // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ - // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ - // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ - // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ - // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ - // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ - // "newLine": "crlf", /* Set the newline character for emitting files. */ - // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ - // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ - // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ - // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ - // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ - // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ - - /* Interop Constraints */ - // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ - // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ - "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ - // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ - "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ - - /* Type Checking */ - "strict": true, /* Enable all strict type-checking options. */ - // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ - // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ - // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ - // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ - // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ - // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ - // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ - // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ - // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ - // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ - // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ - // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ - // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ - // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ - // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ - // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ - // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ - // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ - - /* Completeness */ - // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ - "skipLibCheck": true, /* Skip type checking all .d.ts files. */ - }, - "include": ["src/**/*"] -} diff --git a/tsconfig.esm.json b/tsconfig.dts.json similarity index 91% rename from tsconfig.esm.json rename to tsconfig.dts.json index 887c6e7..cd54e87 100644 --- a/tsconfig.esm.json +++ b/tsconfig.dts.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "preserveSymlinks": true, /* Visit https://aka.ms/tsconfig to read more about this file */ /* Projects */ @@ -12,7 +13,7 @@ /* Language and Environment */ "target": "ES2021", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ - "lib": ["ESNext", "DOM", "ScriptHost"], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + "lib": ["ESNext"], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ // "jsx": "preserve", /* Specify what JSX code is generated. */ // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ @@ -26,16 +27,16 @@ /* Modules */ "module": "ES2022", /* Specify what module code is generated. */ - // "rootDir": "./src", /* Specify the root folder within your source files. */ + "rootDir": "./src", /* Specify the root folder within your source files. */ "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ - "baseUrl": "./src", /* Specify the base directory to resolve non-relative module names. */ + // "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. */ // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ - "types": ["bun-types"], /* Specify type package names to be included without being referenced in a source file. */ + // "types": ["bun-types"], /* Specify type package names to be included without being referenced in a source file. */ // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ - // "resolveJsonModule": true, /* Enable importing .json files. */ + "resolveJsonModule": true, /* Enable importing .json files. */ // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ /* JavaScript Support */ @@ -46,7 +47,7 @@ /* Emit */ "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ // "declarationMap": true, /* Create sourcemaps for d.ts files. */ - // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ + "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ "outDir": "./dist", /* Specify an output folder for all emitted files. */ @@ -70,7 +71,7 @@ /* Interop Constraints */ // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ - // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ + "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ @@ -100,5 +101,6 @@ // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ "skipLibCheck": true, /* Skip type checking all .d.ts files. */ }, - "include": ["src/**/*"] + "exclude": ["node_modules", "test", "example", "dist", "build.ts"] + // "include": ["src/**/*"] }