🧹 chore: support optional path parameter

This commit is contained in:
saltyaom
2024-07-15 17:38:36 +07:00
parent ff6e88ec29
commit ebf0e54269
13 changed files with 753 additions and 736 deletions

View File

@@ -19,4 +19,7 @@ CHANGELOG.md
.eslintrc.js .eslintrc.js
tsconfig.cjs.json tsconfig.cjs.json
tsconfig.esm.json tsconfig.esm.json
tsconfig.dts.json
src
build.ts

7
.prettierrc Normal file
View File

@@ -0,0 +1,7 @@
{
"useTabs": true,
"tabWidth": 4,
"semi": false,
"singleQuote": true,
"trailingComma": "none"
}

View File

@@ -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 # 1.0.2 - 18 Mar 2024
Change: Change:
- Add support for Elysia 1.0 - Add support for Elysia 1.0

37
build.ts Normal file
View File

@@ -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()

BIN
bun.lockb

Binary file not shown.

View File

@@ -39,7 +39,6 @@ const app = new Elysia()
} }
}) })
) )
.use(plugin) // .use(plugin)
.get('/id/:id?', 'a')
.listen(3000) .listen(3000)
console.log(app.routes)

View File

@@ -1,55 +1,81 @@
{ {
"name": "@elysiajs/swagger", "name": "@elysiajs/swagger",
"version": "1.0.4", "version": "1.1.0-rc.1",
"description": "Plugin for Elysia to auto-generate Swagger page", "description": "Plugin for Elysia to auto-generate Swagger page",
"author": { "author": {
"name": "saltyAom", "name": "saltyAom",
"url": "https://github.com/SaltyAom", "url": "https://github.com/SaltyAom",
"email": "saltyaom@gmail.com" "email": "saltyaom@gmail.com"
}, },
"main": "./dist/index.js", "main": "./dist/cjs/index.js",
"exports": { "module": "./dist/index.mjs",
"bun": "./dist/index.js", "types": "./dist/index.d.ts",
"node": "./dist/cjs/index.js", "exports": {
"require": "./dist/cjs/index.js", "./package.json": "./package.json",
"import": "./dist/index.js", ".": {
"default": "./dist/index.js" "types": "./dist/index.d.ts",
}, "import": "./dist/index.mjs",
"types": "./dist/index.d.ts", "require": "./dist/cjs/index.js"
"keywords": [ },
"elysia", "./types": {
"swagger" "types": "./dist/types.d.ts",
], "import": "./dist/types.mjs",
"homepage": "https://github.com/elysiajs/elysia-swagger", "require": "./dist/cjs/types.js"
"repository": { },
"type": "git", "./utils": {
"url": "https://github.com/elysiajs/elysia-swagger" "types": "./dist/utils.d.ts",
}, "import": "./dist/utils.mjs",
"bugs": "https://github.com/elysiajs/elysia-swagger/issues", "require": "./dist/cjs/utils.js"
"license": "MIT", },
"scripts": { "./scalar": {
"dev": "bun run --watch example/index.ts", "types": "./dist/scalar/index.d.ts",
"test": "bun test && npm run test:node", "import": "./dist/scalar/index.mjs",
"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", "require": "./dist/cjs/scalar/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" "./scalar/theme": {
}, "types": "./dist/scalar/theme.d.ts",
"peerDependencies": { "import": "./dist/scalar/theme.mjs",
"elysia": ">= 1.0.2" "require": "./dist/cjs/scalar/theme.js"
}, },
"devDependencies": { "./scalar/types": {
"@apidevtools/swagger-parser": "^10.1.0", "types": "./dist/scalar/types/index.d.ts",
"@scalar/api-reference": "^1.12.5", "import": "./dist/scalar/types/index.mjs",
"@types/bun": "^1.0.4", "require": "./dist/cjs/scalar/types/index.js"
"@types/lodash.clonedeep": "^4.5.7", }
"@types/node": "^20.1.4", },
"elysia": "1.0.2", "keywords": [
"eslint": "^8.40.0", "elysia",
"rimraf": "4.3", "swagger"
"typescript": "^5.0.4" ],
}, "homepage": "https://github.com/elysiajs/elysia-swagger",
"dependencies": { "repository": {
"lodash.clonedeep": "^4.5.0", "type": "git",
"openapi-types": "^12.1.3" "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"
}
} }

View File

@@ -1,171 +1,165 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */ /* 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 { SwaggerUIRender } from "./swagger";
import { ScalarRender } from './scalar' import { ScalarRender } from "./scalar";
import { filterPaths, registerSchemaPath } from './utils' import { filterPaths, registerSchemaPath } from "./utils";
import type { OpenAPIV3 } from 'openapi-types' import type { OpenAPIV3 } from "openapi-types";
import type { ReferenceConfiguration } from './scalar/types' import type { ReferenceConfiguration } from "./scalar/types";
import type { ElysiaSwaggerConfig } from './types' import type { ElysiaSwaggerConfig } 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 Swagger page.
* *
* @see https://github.com/elysiajs/elysia-swagger * @see https://github.com/elysiajs/elysia-swagger
*/ */
export const swagger = export const swagger = async <Path extends string = "/swagger">(
<Path extends string = '/swagger'>( {
{ provider = "scalar",
provider = 'scalar', scalarVersion = "latest",
scalarVersion = 'latest', scalarCDN = "",
scalarCDN = '', scalarConfig = {},
scalarConfig = {}, documentation = {},
documentation = {}, version = "5.9.0",
version = '5.9.0', excludeStaticFile = true,
excludeStaticFile = true, path = "/swagger" as Path,
path = '/swagger' as Path, exclude = [],
exclude = [], swaggerOptions = {},
swaggerOptions = {}, theme = `https://unpkg.com/swagger-ui-dist@${version}/swagger-ui.css`,
theme = `https://unpkg.com/swagger-ui-dist@${version}/swagger-ui.css`, autoDarkMode = true,
autoDarkMode = true, excludeMethods = ["OPTIONS"],
excludeMethods = ['OPTIONS'], excludeTags = [],
excludeTags = [] }: ElysiaSwaggerConfig<Path> = {
}: ElysiaSwaggerConfig<Path> = { provider: "scalar",
provider: 'scalar', scalarVersion: "latest",
scalarVersion: 'latest', scalarCDN: "",
scalarCDN: '', scalarConfig: {},
scalarConfig: {}, documentation: {},
documentation: {}, version: "5.9.0",
version: '5.9.0', excludeStaticFile: true,
excludeStaticFile: true, path: "/swagger" as Path,
path: '/swagger' as Path, exclude: [],
exclude: [], swaggerOptions: {},
swaggerOptions: {}, autoDarkMode: true,
autoDarkMode: true, excludeMethods: ["OPTIONS"],
excludeMethods: ['OPTIONS'], excludeTags: [],
excludeTags: [] },
} ) => {
) => const schema = {};
(app: Elysia) => { let totalRoutes = 0;
const schema = {}
let totalRoutes = 0
if (!version) if (!version)
version = `https://unpkg.com/swagger-ui-dist@${version}/swagger-ui.css` version = `https://unpkg.com/swagger-ui-dist@${version}/swagger-ui.css`;
const info = { const info = {
title: 'Elysia Documentation', title: "Elysia Documentation",
description: 'Development documentation', description: "Development documentation",
version: '0.0.0', version: "0.0.0",
...documentation.info ...documentation.info,
} };
const relativePath = path.startsWith('/') ? path.slice(1) : path const relativePath = path.startsWith("/") ? path.slice(1) : path;
app.get( const app = new Elysia({ name: "@elysiajs/swagger" });
path,
() => {
const combinedSwaggerOptions = {
url: `${relativePath}/json`,
dom_id: '#swagger-ui',
...swaggerOptions
}
const stringifiedSwaggerOptions = JSON.stringify( app.get(path, function documentation() {
combinedSwaggerOptions, const combinedSwaggerOptions = {
(key, value) => { url: `${relativePath}/json`,
if (typeof value == 'function') return undefined dom_id: "#swagger-ui",
...swaggerOptions,
};
return value const stringifiedSwaggerOptions = JSON.stringify(
} combinedSwaggerOptions,
) (key, value) => {
if (typeof value == "function") return undefined;
const scalarConfiguration: ReferenceConfiguration = { return value;
spec: { },
...scalarConfig.spec, );
url: `${relativePath}/json`,
},
...scalarConfig
}
return new Response( const scalarConfiguration: ReferenceConfiguration = {
provider === 'swagger-ui' spec: {
? SwaggerUIRender( ...scalarConfig.spec,
info, url: `${relativePath}/json`,
version, },
theme, ...scalarConfig,
stringifiedSwaggerOptions, };
autoDarkMode
)
: ScalarRender(
scalarVersion,
scalarConfiguration,
scalarCDN
),
{
headers: {
'content-type': 'text/html; charset=utf8'
}
}
)
}
).get(
path === '/' ? '/json' : `${path}/json`,
() => {
const routes = app.routes as InternalRoute[]
if (routes.length !== totalRoutes) { return new Response(
totalRoutes = routes.length 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 (routes.length !== totalRoutes) {
if (excludeMethods.includes(route.method)) return totalRoutes = routes.length;
registerSchemaPath({ routes.forEach((route: InternalRoute) => {
schema, if (excludeMethods.includes(route.method)) return;
hook: route.hooks,
method: route.method,
path: route.path,
// @ts-ignore
models: app.definitions?.type,
contentType: route.hooks.type
})
})
}
return { registerSchemaPath({
openapi: '3.0.3', schema,
...{ hook: route.hooks,
...documentation, method: route.method,
tags: documentation.tags?.filter((tag) => !excludeTags?.includes(tag?.name)), path: route.path,
info: { // @ts-ignore
title: 'Elysia Documentation', models: app.definitions?.type,
description: 'Development documentation', contentType: route.hooks.type,
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
}
)
// This is intentional to prevent deeply nested type return {
return app 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;

View File

@@ -8,338 +8,352 @@ import type { OpenAPIV3 } from 'openapi-types'
import deepClone from 'lodash.clonedeep' import deepClone from 'lodash.clonedeep'
export const toOpenAPIPath = (path: string) => export const toOpenAPIPath = (path: string) =>
path path
.split('/') .split('/')
.map((x) => (x.startsWith(':') ? `{${x.slice(1, x.length)}}` : x)) .map((x) => {
.join('/') 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 = ( export const mapProperties = (
name: string, name: string,
schema: TSchema | string | undefined, schema: TSchema | string | undefined,
models: Record<string, TSchema> models: Record<string, TSchema>
) => { ) => {
if (schema === undefined) return [] if (schema === undefined) return []
if (typeof schema === 'string') if (typeof schema === 'string')
if (schema in models) schema = models[schema] if (schema in models) schema = models[schema]
else throw new Error(`Can't find model ${schema}`) else throw new Error(`Can't find model ${schema}`)
return Object.entries(schema?.properties ?? []).map(([key, value]) => { return Object.entries(schema?.properties ?? []).map(([key, value]) => {
const { type: valueType = undefined, description, examples, ...schemaKeywords } = value as any const {
return { type: valueType = undefined,
// @ts-ignore description,
description, examples, examples,
schema: { type: valueType, ...schemaKeywords }, ...schemaKeywords
in: name, } = value as any
name: key, return {
// @ts-ignore // @ts-ignore
required: schema!.required?.includes(key) ?? false description,
} examples,
}) schema: { type: valueType, ...schemaKeywords },
in: name,
name: key,
// @ts-ignore
required: schema!.required?.includes(key) ?? false
}
})
} }
const mapTypesResponse = ( const mapTypesResponse = (
types: string[], types: string[],
schema: schema:
| string | string
| { | {
type: string type: string
properties: Object properties: Object
required: string[] required: string[]
} }
) => { ) => {
if ( if (
typeof schema === 'object' && typeof schema === 'object' &&
['void', 'undefined', 'null'].includes(schema.type) ['void', 'undefined', 'null'].includes(schema.type)
) )
return return
const responses: Record<string, OpenAPIV3.MediaTypeObject> = {} const responses: Record<string, OpenAPIV3.MediaTypeObject> = {}
for (const type of types) for (const type of types)
responses[type] = { responses[type] = {
schema: schema:
typeof schema === 'string' typeof schema === 'string'
? { ? {
$ref: `#/components/schemas/${schema}` $ref: `#/components/schemas/${schema}`
} }
: { ...(schema as any) } : { ...(schema as any) }
} }
return responses return responses
} }
export const capitalize = (word: string) => 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) => { 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('/')) { for (const path of paths.split('/')) {
if (path.charCodeAt(0) === 123) { if (path.charCodeAt(0) === 123) {
operationId += 'By' + capitalize(path.slice(1, -1)) operationId += 'By' + capitalize(path.slice(1, -1))
} else { } else {
operationId += capitalize(path) operationId += capitalize(path)
} }
} }
return operationId return operationId
} }
export const registerSchemaPath = ({ export const registerSchemaPath = ({
schema, schema,
path, path,
method, method,
hook, hook,
models models
}: { }: {
schema: Partial<OpenAPIV3.PathsObject> schema: Partial<OpenAPIV3.PathsObject>
contentType?: string | string[] contentType?: string | string[]
path: string path: string
method: HTTPMethod method: HTTPMethod
hook?: LocalHook<any, any, any, any, any, any, any> hook?: LocalHook<any, any, any, any, any, any, any>
models: Record<string, TSchema> models: Record<string, TSchema>
}) => { }) => {
if (hook) hook = deepClone(hook) if (hook) hook = deepClone(hook)
const contentType = hook?.type ?? [ const contentType = hook?.type ?? [
'application/json', 'application/json',
'multipart/form-data', 'multipart/form-data',
'text/plain' 'text/plain'
] ]
path = toOpenAPIPath(path) path = toOpenAPIPath(path)
const contentTypes = const contentTypes =
typeof contentType === 'string' typeof contentType === 'string'
? [contentType] ? [contentType]
: contentType ?? ['application/json'] : contentType ?? ['application/json']
const bodySchema = hook?.body const bodySchema = hook?.body
const paramsSchema = hook?.params const paramsSchema = hook?.params
const headerSchema = hook?.headers const headerSchema = hook?.headers
const querySchema = hook?.query const querySchema = hook?.query
let responseSchema = hook?.response as unknown as OpenAPIV3.ResponsesObject let responseSchema = hook?.response as unknown as OpenAPIV3.ResponsesObject
if (typeof responseSchema === 'object') { if (typeof responseSchema === 'object') {
if (Kind in responseSchema) { if (Kind in responseSchema) {
const { const {
type, type,
properties, properties,
required, required,
additionalProperties, additionalProperties,
patternProperties, patternProperties,
...rest ...rest
} = responseSchema as typeof responseSchema & { } = responseSchema as typeof responseSchema & {
type: string type: string
properties: Object properties: Object
required: string[] required: string[]
} }
responseSchema = { responseSchema = {
'200': { '200': {
...rest, ...rest,
description: rest.description as any, description: rest.description as any,
content: mapTypesResponse( content: mapTypesResponse(
contentTypes, contentTypes,
type === 'object' || type === 'array' type === 'object' || type === 'array'
? ({ ? ({
type, type,
properties, properties,
patternProperties, patternProperties,
items: responseSchema.items, items: responseSchema.items,
required required
} as any) } as any)
: responseSchema : responseSchema
) )
} }
} }
} else { } else {
Object.entries(responseSchema as Record<string, TSchema>).forEach( Object.entries(responseSchema as Record<string, TSchema>).forEach(
([key, value]) => { ([key, value]) => {
if (typeof value === 'string') { if (typeof value === 'string') {
if (!models[value]) return if (!models[value]) return
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
const { const {
type, type,
properties, properties,
required, required,
additionalProperties: _1, additionalProperties: _1,
patternProperties: _2, patternProperties: _2,
...rest ...rest
} = models[value] as TSchema & { } = models[value] as TSchema & {
type: string type: string
properties: Object properties: Object
required: string[] required: string[]
} }
responseSchema[key] = { responseSchema[key] = {
...rest, ...rest,
description: rest.description as any, description: rest.description as any,
content: mapTypesResponse(contentTypes, value) content: mapTypesResponse(contentTypes, value)
} }
} else { } else {
const { const {
type, type,
properties, properties,
required, required,
additionalProperties, additionalProperties,
patternProperties, patternProperties,
...rest ...rest
} = value as typeof value & { } = value as typeof value & {
type: string type: string
properties: Object properties: Object
required: string[] required: string[]
} }
responseSchema[key] = { responseSchema[key] = {
...rest, ...rest,
description: rest.description as any, description: rest.description as any,
content: mapTypesResponse( content: mapTypesResponse(
contentTypes, contentTypes,
type === 'object' || type === 'array' type === 'object' || type === 'array'
? ({ ? ({
type, type,
properties, properties,
patternProperties, patternProperties,
items: value.items, items: value.items,
required required
} as any) } as any)
: value : value
) )
} }
} }
} }
) )
} }
} else if (typeof responseSchema === 'string') { } else if (typeof responseSchema === 'string') {
if (!(responseSchema in models)) return if (!(responseSchema in models)) return
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
const { const {
type, type,
properties, properties,
required, required,
additionalProperties: _1, additionalProperties: _1,
patternProperties: _2, patternProperties: _2,
...rest ...rest
} = models[responseSchema] as TSchema & { } = models[responseSchema] as TSchema & {
type: string type: string
properties: Object properties: Object
required: string[] required: string[]
} }
responseSchema = { responseSchema = {
// @ts-ignore // @ts-ignore
'200': { '200': {
...rest, ...rest,
content: mapTypesResponse(contentTypes, responseSchema) content: mapTypesResponse(contentTypes, responseSchema)
} }
} }
} }
const parameters = [ const parameters = [
...mapProperties('header', headerSchema, models), ...mapProperties('header', headerSchema, models),
...mapProperties('path', paramsSchema, models), ...mapProperties('path', paramsSchema, models),
...mapProperties('query', querySchema, models) ...mapProperties('query', querySchema, models)
] ]
schema[path] = { schema[path] = {
...(schema[path] ? schema[path] : {}), ...(schema[path] ? schema[path] : {}),
[method.toLowerCase()]: { [method.toLowerCase()]: {
...((headerSchema || paramsSchema || querySchema || bodySchema ...((headerSchema || paramsSchema || querySchema || bodySchema
? ({ parameters } as any) ? ({ parameters } as any)
: {}) satisfies OpenAPIV3.ParameterObject), : {}) satisfies OpenAPIV3.ParameterObject),
...(responseSchema ...(responseSchema
? { ? {
responses: responseSchema responses: responseSchema
} }
: {}), : {}),
operationId: operationId:
hook?.detail?.operationId ?? generateOperationId(method, path), hook?.detail?.operationId ?? generateOperationId(method, path),
...hook?.detail, ...hook?.detail,
...(bodySchema ...(bodySchema
? { ? {
requestBody: { requestBody: {
required: true, required: true,
content: mapTypesResponse( content: mapTypesResponse(
contentTypes, contentTypes,
typeof bodySchema === 'string' typeof bodySchema === 'string'
? { ? {
$ref: `#/components/schemas/${bodySchema}` $ref: `#/components/schemas/${bodySchema}`
} }
: (bodySchema as any) : (bodySchema as any)
) )
} }
} }
: null) : null)
} satisfies OpenAPIV3.OperationObject } satisfies OpenAPIV3.OperationObject
} }
} }
export const filterPaths = ( export const filterPaths = (
paths: Record<string, any>, paths: Record<string, any>,
{ {
excludeStaticFile = true, excludeStaticFile = true,
exclude = [] exclude = []
}: { }: {
excludeStaticFile: boolean excludeStaticFile: boolean
exclude: (string | RegExp)[] exclude: (string | RegExp)[]
} }
) => { ) => {
const newPaths: Record<string, any> = {} const newPaths: Record<string, any> = {}
for (const [key, value] of Object.entries(paths)) for (const [key, value] of Object.entries(paths))
if ( if (
!exclude.some((x) => { !exclude.some((x) => {
if (typeof x === 'string') return key === x if (typeof x === 'string') return key === x
return x.test(key) return x.test(key)
}) && }) &&
!key.includes('/swagger') && !key.includes('/swagger') &&
!key.includes('*') && !key.includes('*') &&
(excludeStaticFile ? !key.includes('.') : true) (excludeStaticFile ? !key.includes('.') : true)
) { ) {
Object.keys(value).forEach((method) => { Object.keys(value).forEach((method) => {
const schema = value[method] const schema = value[method]
if (key.includes('{')) { if (key.includes('{')) {
if (!schema.parameters) schema.parameters = [] if (!schema.parameters) schema.parameters = []
schema.parameters = [ schema.parameters = [
...key ...key
.split('/') .split('/')
.filter( .filter(
(x) => (x) =>
x.startsWith('{') && x.startsWith('{') &&
!schema.parameters.find( !schema.parameters.find(
(params: Record<string, any>) => (params: Record<string, any>) =>
params.in === 'path' && params.in === 'path' &&
params.name === params.name ===
x.slice(1, x.length - 1) x.slice(1, x.length - 1)
) )
) )
.map((x) => ({ .map((x) => ({
schema: { type: 'string' }, schema: { type: 'string' },
in: 'path', in: 'path',
name: x.slice(1, x.length - 1), name: x.slice(1, x.length - 1),
required: true required: true
})), })),
...schema.parameters ...schema.parameters
] ]
} }
if (!schema.responses) if (!schema.responses)
schema.responses = { schema.responses = {
200: {} 200: {}
} }
}) })
newPaths[key] = value newPaths[key] = value
} }
return newPaths return newPaths
} }

View File

@@ -8,164 +8,196 @@ import { fail } from 'assert'
const req = (path: string) => new Request(`http://localhost${path}`) const req = (path: string) => new Request(`http://localhost${path}`)
describe('Swagger', () => { describe('Swagger', () => {
it('show Swagger page', async () => { it('show Swagger page', async () => {
const app = new Elysia().use(swagger()) const app = new Elysia().use(swagger())
const res = await app.handle(req('/swagger')) await app.modules
expect(res.status).toBe(200)
})
it('returns a valid Swagger/OpenAPI json config', async () => { const res = await app.handle(req('/swagger'))
const app = new Elysia().use(swagger()) expect(res.status).toBe(200)
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))
})
it('use custom Swagger version', async () => { it('returns a valid Swagger/OpenAPI json config', async () => {
const app = new Elysia().use( const app = new Elysia().use(swagger())
swagger({
provider: 'swagger-ui',
version: '4.5.0'
})
)
const res = await app.handle(req('/swagger')).then((x) => x.text()) await app.modules
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 res = await app.handle(req('/swagger/json')).then((x) => x.json())
const app = new Elysia().use( expect(res.openapi).toBe('3.0.3')
swagger({ await SwaggerParser.validate(res).catch((err) => fail(err))
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')).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('<title>Elysia Documentation</title>')).toBe(true) await app.modules
expect(
res.includes( const res = await app.handle(req('/swagger')).then((x) => x.text())
`<meta 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('<title>Elysia Documentation</title>')).toBe(true)
expect(
res.includes(
`<meta
name="description" name="description"
content="Herrscher of Human" content="Herrscher of Human"
/>` />`
) )
).toBe(true) ).toBe(true)
}) })
it('use custom path', async () => { it('use custom path', async () => {
const app = new Elysia().use( const app = new Elysia().use(
swagger({ swagger({
path: '/v2/swagger' path: '/v2/swagger'
}) })
) )
const res = await app.handle(req('/v2/swagger')) await app.modules
expect(res.status).toBe(200)
const resJson = await app.handle(req('/v2/swagger/json')) const res = await app.handle(req('/v2/swagger'))
expect(resJson.status).toBe(200) expect(res.status).toBe(200)
})
it('Swagger UI options', async () => { const resJson = await app.handle(req('/v2/swagger/json'))
const app = new Elysia().use( expect(resJson.status).toBe(200)
swagger({ })
provider: 'swagger-ui',
swaggerOptions: {
persistAuthorization: true
}
})
)
const res = await app.handle(req('/swagger')).then((x) => x.text())
const expected = `"persistAuthorization":true`
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 () => { await app.modules
const app = new Elysia().use(swagger()).get('/void', () => {}, {
response: {
204: t.Void({
description: 'Void response'
})
}
})
const res = await app.handle(req('/swagger/json')) const res = await app.handle(req('/swagger')).then((x) => x.text())
expect(res.status).toBe(200) const expected = `"persistAuthorization":true`
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 not return content response when using Undefined type', async () => { expect(res.trim().includes(expected.trim())).toBe(true)
const app = new Elysia() })
.use(swagger())
.get('/undefined', () => undefined, {
response: {
204: t.Undefined({
description: 'Undefined response'
})
}
})
const res = await app.handle(req('/swagger/json')) it('should not return content response when using Void type', async () => {
expect(res.status).toBe(200) const app = new Elysia().use(swagger()).get('/void', () => {}, {
const response = await res.json() response: {
expect( 204: t.Void({
response.paths['/undefined'].get.responses['204'].description description: 'Void response'
).toBe('Undefined response') })
expect( }
response.paths['/undefined'].get.responses['204'].content })
).toBeUndefined()
})
it('should not return content response when using Null type', async () => { await app.modules
const app = new Elysia().use(swagger()).get('/null', () => null, {
response: {
204: t.Null({
description: 'Null response'
})
}
})
const res = await app.handle(req('/swagger/json')) const res = await app.handle(req('/swagger/json'))
expect(res.status).toBe(200) expect(res.status).toBe(200)
const response = await res.json() const response = await res.json()
expect(response.paths['/null'].get.responses['204'].description).toBe( expect(response.paths['/void'].get.responses['204'].description).toBe(
'Null response' 'Void response'
) )
expect( expect(
response.paths['/null'].get.responses['204'].content response.paths['/void'].get.responses['204'].content
).toBeUndefined() ).toBeUndefined()
}) })
it("should set the required field to true when a request body is present", async () => { it('should not return content response when using Undefined type', async () => {
const app = new Elysia().use(swagger()).post("/post", () => {}, { const app = new Elysia()
body: t.Object({ name: t.String() }), .use(swagger())
}); .get('/undefined', () => undefined, {
response: {
204: t.Undefined({
description: 'Undefined response'
})
}
})
const res = await app.handle(req("/swagger/json")); await app.modules
expect(res.status).toBe(200);
const response = await res.json();
expect(response.paths['/post'].post.requestBody.required).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 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}')
})
}) })

View File

@@ -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()) const res = await app.handle(req('/swagger/json')).then((x) => x.json())
await SwaggerParser.validate(res).catch((err) => fail(err)) await SwaggerParser.validate(res).catch((err) => fail(err))
}) })

View File

@@ -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 '<reference>'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/**/*"]
}

View File

@@ -1,5 +1,6 @@
{ {
"compilerOptions": { "compilerOptions": {
"preserveSymlinks": true,
/* Visit https://aka.ms/tsconfig to read more about this file */ /* Visit https://aka.ms/tsconfig to read more about this file */
/* Projects */ /* Projects */
@@ -12,7 +13,7 @@
/* Language and Environment */ /* Language and Environment */
"target": "ES2021", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ "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. */ // "jsx": "preserve", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
@@ -26,16 +27,16 @@
/* Modules */ /* Modules */
"module": "ES2022", /* Specify what module code is generated. */ "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. */ "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. */ // "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. */ // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ // "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. */ // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ // "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 '<reference>'s from expanding the number of files TypeScript should add to a project. */ // "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */ /* JavaScript Support */
@@ -46,7 +47,7 @@
/* Emit */ /* Emit */
"declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */ // "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. */ // "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. */ // "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. */ "outDir": "./dist", /* Specify an output folder for all emitted files. */
@@ -70,7 +71,7 @@
/* Interop Constraints */ /* Interop Constraints */
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ // "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. */ "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. */ // "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. */ "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. */ // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true, /* Skip type checking all .d.ts files. */ "skipLibCheck": true, /* Skip type checking all .d.ts files. */
}, },
"include": ["src/**/*"] "exclude": ["node_modules", "test", "example", "dist", "build.ts"]
// "include": ["src/**/*"]
} }