mirror of
https://github.com/zoriya/elysia-swagger.git
synced 2025-12-06 08:46:10 +00:00
Compare commits
10 Commits
1.1.4
...
fix/schema
| Author | SHA1 | Date | |
|---|---|---|---|
| 8cbd254d20 | |||
| f3ac692c82 | |||
| 46a91688f0 | |||
|
|
ed00255e52 | ||
|
|
062995eb4d | ||
|
|
f5066d0eb3 | ||
|
|
9ed31680e3 | ||
|
|
b89ab28906 | ||
|
|
9e4a163c63 | ||
|
|
55b86f0335 |
11
CHANGELOG.md
11
CHANGELOG.md
@@ -1,4 +1,13 @@
|
||||
# 1.1.14 - 9 Oct 2024
|
||||
|
||||
# 1.2.0-rc.0 - 23 Dec 2024
|
||||
Change:
|
||||
- Add support for Elysia 1.2
|
||||
|
||||
# 1.1.6 - 17 Nov 2024
|
||||
Bug fix:
|
||||
- [#156](https://github.com/elysiajs/elysia-swagger/pull/156) add type check in cloneHook
|
||||
|
||||
# 1.1.4 - 9 Oct 2024
|
||||
Bug fix:
|
||||
- Fix duplicate object reference
|
||||
|
||||
|
||||
21
package.json
21
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@elysiajs/swagger",
|
||||
"version": "1.1.4",
|
||||
"version": "1.2.0",
|
||||
"description": "Plugin for Elysia to auto-generate Swagger page",
|
||||
"author": {
|
||||
"name": "saltyAom",
|
||||
@@ -57,19 +57,20 @@
|
||||
"release": "npm run build && npm run test && npm publish --access public"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"elysia": ">= 1.1.0"
|
||||
"elysia": ">= 1.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@apidevtools/swagger-parser": "^10.1.0",
|
||||
"@types/bun": "1.1.6",
|
||||
"elysia": "1.1.18",
|
||||
"eslint": "9.6.0",
|
||||
"tsup": "^8.1.0",
|
||||
"typescript": "^5.5.3"
|
||||
"@types/bun": "1.1.14",
|
||||
"elysia": "^1.2.10",
|
||||
"eslint": "9.17.0",
|
||||
"tsup": "^8.3.5",
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@scalar/types": "^0.0.12",
|
||||
"@scalar/themes": "^0.9.58",
|
||||
"@scalar/types": "^0.0.25",
|
||||
"openapi-types": "^12.1.3",
|
||||
"pathe": "^1.1.2"
|
||||
"pathe": "^2.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import type { ElysiaSwaggerConfig } from './types'
|
||||
*
|
||||
* @see https://github.com/elysiajs/elysia-swagger
|
||||
*/
|
||||
export const swagger = async <Path extends string = '/swagger'>(
|
||||
export const swagger = <Path extends string = '/swagger'>(
|
||||
{
|
||||
provider = 'scalar',
|
||||
scalarVersion = 'latest',
|
||||
@@ -85,7 +85,9 @@ export const swagger = async <Path extends string = '/swagger'>(
|
||||
...scalarConfig.spec,
|
||||
url: `/${relativePath}/json`
|
||||
},
|
||||
...scalarConfig
|
||||
...scalarConfig,
|
||||
// so we can showcase the elysia theme
|
||||
_integration: 'elysiajs'
|
||||
}
|
||||
|
||||
return new Response(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import scalarElysiaTheme from './theme'
|
||||
import { elysiajsTheme } from '@scalar/themes'
|
||||
import type { OpenAPIV3 } from 'openapi-types'
|
||||
import type { ReferenceConfiguration } from '@scalar/types'
|
||||
|
||||
@@ -29,7 +29,7 @@ export const ScalarRender = (
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
${config.customCss ?? scalarElysiaTheme}
|
||||
${config.customCss ?? elysiajsTheme}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -1,196 +0,0 @@
|
||||
export default `
|
||||
/* basic theme */
|
||||
.light-mode {
|
||||
--theme-color-1: #2a2f45;
|
||||
--theme-color-2: #757575;
|
||||
--theme-color-3: #8e8e8e;
|
||||
--theme-color-accent: #f06292;
|
||||
|
||||
--theme-background-1: #fff;
|
||||
--theme-background-2: #f6f6f6;
|
||||
--theme-background-3: #e7e7e7;
|
||||
--theme-background-accent: #f062921f;
|
||||
|
||||
--theme-border-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.dark-mode {
|
||||
--theme-color-1: rgba(255, 255, 255, 0.9);
|
||||
--theme-color-2: rgba(156, 163, 175, 1);
|
||||
--theme-color-3: rgba(255, 255, 255, 0.44);
|
||||
--theme-color-accent: #f06292;
|
||||
|
||||
--theme-background-1: #111728;
|
||||
--theme-background-2: #1e293b;
|
||||
--theme-background-3: #334155;
|
||||
--theme-background-accent: #f062921f;
|
||||
|
||||
--theme-border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
/* Document Sidebar */
|
||||
.light-mode .sidebar,
|
||||
.dark-mode .sidebar {
|
||||
--sidebar-background-1: var(--theme-background-1);
|
||||
--sidebar-item-hover-color: currentColor;
|
||||
--sidebar-item-hover-background: var(--theme-background-2);
|
||||
--sidebar-item-active-background: var(--theme-background-accent);
|
||||
--sidebar-border-color: transparent;
|
||||
--sidebar-color-1: var(--theme-color-1);
|
||||
--sidebar-color-2: var(--theme-color-2);
|
||||
--sidebar-color-active: var(--theme-color-accent);
|
||||
--sidebar-search-background: transparent;
|
||||
--sidebar-search-border-color: var(--theme-border-color);
|
||||
--sidebar-search--color: var(--theme-color-3);
|
||||
}
|
||||
/* Document header only shows on mobile*/
|
||||
.dark-mode .t-doc__header,
|
||||
.light-mode .t-doc__header {
|
||||
--header-background-1: rgba(255, 255, 255, 0.85);
|
||||
--header-border-color: transparent;
|
||||
--header-color-1: var(--theme-color-1);
|
||||
--header-color-2: var(--theme-color-2);
|
||||
--header-background-toggle: var(--theme-color-3);
|
||||
--header-call-to-action-color: var(--theme-color-accent);
|
||||
}
|
||||
|
||||
.dark-mode .t-doc__header {
|
||||
--header-background-1: rgba(17, 23, 40, 0.75);
|
||||
}
|
||||
|
||||
/* advanced */
|
||||
.light-mode {
|
||||
--theme-button-1: rgb(49 53 56);
|
||||
--theme-button-1-color: #fff;
|
||||
--theme-button-1-hover: rgb(28 31 33);
|
||||
|
||||
--theme-color-green: #069061;
|
||||
--theme-color-red: #ef0006;
|
||||
--theme-color-yellow: #edbe20;
|
||||
--theme-color-blue: #0082d0;
|
||||
--theme-color-orange: #fb892c;
|
||||
--theme-color-purple: #5203d1;
|
||||
|
||||
--theme-scrollbar-color: rgba(0, 0, 0, 0.18);
|
||||
--theme-scrollbar-color-active: rgba(0, 0, 0, 0.36);
|
||||
}
|
||||
.dark-mode {
|
||||
--theme-button-1: #f6f6f6;
|
||||
--theme-button-1-color: #000;
|
||||
--theme-button-1-hover: #e7e7e7;
|
||||
|
||||
--theme-color-green: #a3ffa9;
|
||||
--theme-color-red: #ffa3a3;
|
||||
--theme-color-yellow: #fffca3;
|
||||
--theme-color-blue: #a5d6ff;
|
||||
--theme-color-orange: #e2ae83;
|
||||
--theme-color-purple: #d2a8ff;
|
||||
|
||||
--theme-scrollbar-color: rgba(255, 255, 255, 0.24);
|
||||
--theme-scrollbar-color-active: rgba(255, 255, 255, 0.48);
|
||||
}
|
||||
/* Elysia Specific */
|
||||
.scalar-api-client__send-request-button,
|
||||
.show-api-client-button {
|
||||
background: #3c82f6 !important;
|
||||
}
|
||||
.show-api-client-button:before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar-search:hover {
|
||||
transition: all 0.15s ease-in-out;
|
||||
--sidebar-search-border-color: var(--theme-color-accent) !important;
|
||||
color: var(--sidebar-color-1) !important;
|
||||
}
|
||||
.scalar-api-client__container .sidebar {
|
||||
--sidebar-border-color: var(--theme-border-color);
|
||||
}
|
||||
@media (min-width: 1150px) {
|
||||
.section-container:has( ~ .footer):before,
|
||||
.tag-section-container:before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 10px;
|
||||
background: linear-gradient(90deg, var(--theme-background-1) 3%,transparent 10%);
|
||||
}
|
||||
}
|
||||
.section-flare {
|
||||
position: absolute;
|
||||
width: 100vw;
|
||||
height: 300px;
|
||||
--stripes: repeating-linear-gradient(
|
||||
100deg,
|
||||
#fff 0%,
|
||||
#fff 7%,
|
||||
transparent 10%,
|
||||
transparent 12%,
|
||||
#fff 16%
|
||||
);
|
||||
--stripesDark: repeating-linear-gradient(
|
||||
100deg,
|
||||
#000 0%,
|
||||
#000 7%,
|
||||
transparent 10%,
|
||||
transparent 12%,
|
||||
#000 16%
|
||||
);
|
||||
--rainbow: repeating-linear-gradient(
|
||||
100deg,
|
||||
#60a5fa 10%,
|
||||
#e879f9 16%,
|
||||
#5eead4 22%,
|
||||
#60a5fa 30%
|
||||
);
|
||||
background-image: var(--stripes), var(--rainbow);
|
||||
background-size: 300%, 200%;
|
||||
background-position: 50% 50%, 50% 50%;
|
||||
filter: invert(100%);
|
||||
-webkit-mask-image: radial-gradient(
|
||||
ellipse at 100% 0%,
|
||||
black 40%,
|
||||
transparent 70%
|
||||
);
|
||||
mask-image: radial-gradient(ellipse at 100% 0%, black 40%, transparent 70%);
|
||||
pointer-events: none;
|
||||
opacity: 0.15;
|
||||
}
|
||||
.dark-mode .section-flare {
|
||||
background-image: var(--stripesDark), var(--rainbow);
|
||||
filter: opacity(50%) saturate(200%);
|
||||
opacity: 0.25;
|
||||
}
|
||||
.section-flare:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
background-image: var(--stripes), var(--rainbow);
|
||||
background-size: 200%, 100%;
|
||||
background-attachment: fixed;
|
||||
mix-blend-mode: difference;
|
||||
}
|
||||
.dark-mode .section-flare:after {
|
||||
background-image: var(--stripesDark), var(--rainbow);
|
||||
}
|
||||
@keyframes headerbackground {
|
||||
from {
|
||||
background: transparent;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
to {
|
||||
background: var(--header-background-1);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
}
|
||||
.light-mode .t-doc__header,
|
||||
.dark-mode .t-doc__header {
|
||||
animation: headerbackground forwards;
|
||||
animation-timeline: scroll();
|
||||
animation-range: 0px 200px;
|
||||
--header-border-color: transparent;
|
||||
}
|
||||
`
|
||||
219
src/utils.ts
219
src/utils.ts
@@ -1,5 +1,3 @@
|
||||
/* eslint-disable @typescript-eslint/ban-ts-comment */
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import { normalize } from 'pathe'
|
||||
import type { HTTPMethod, LocalHook } from 'elysia'
|
||||
|
||||
@@ -24,42 +22,51 @@ export const mapProperties = (
|
||||
name: string,
|
||||
schema: TSchema | string | undefined,
|
||||
models: Record<string, TSchema>
|
||||
) => {
|
||||
): (OpenAPIV3.ReferenceObject | OpenAPIV3.ParameterObject)[] => {
|
||||
if (schema === undefined) return []
|
||||
|
||||
if (typeof schema === 'string')
|
||||
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]) => {
|
||||
// this is used by parameters (query, headers...) and we only support
|
||||
// object like schemas.
|
||||
return Object.entries(schema?.properties as Record<string, TSchema> ?? []).map(([key, value]) => {
|
||||
const {
|
||||
type: valueType = undefined,
|
||||
description,
|
||||
deprecated,
|
||||
allowEmptyValue,
|
||||
style,
|
||||
explode,
|
||||
allowReserved,
|
||||
example,
|
||||
examples,
|
||||
...schemaKeywords
|
||||
} = value as any
|
||||
...schemaVal
|
||||
} = value;
|
||||
let required = schema!.required?.includes(key) ?? false;
|
||||
if ("default" in schemaVal) required = false;
|
||||
|
||||
return {
|
||||
// @ts-ignore
|
||||
description,
|
||||
examples,
|
||||
schema: { type: valueType, ...schemaKeywords },
|
||||
in: name,
|
||||
name: key,
|
||||
// @ts-ignore
|
||||
required: schema!.required?.includes(key) ?? false
|
||||
}
|
||||
in: name,
|
||||
description,
|
||||
required,
|
||||
deprecated,
|
||||
allowEmptyValue,
|
||||
style,
|
||||
explode,
|
||||
allowReserved,
|
||||
example,
|
||||
examples,
|
||||
schema: schemaVal,
|
||||
} satisfies OpenAPIV3.ParameterObject;
|
||||
})
|
||||
}
|
||||
|
||||
const mapTypesResponse = (
|
||||
types: string[],
|
||||
schema:
|
||||
| string
|
||||
| {
|
||||
type: string
|
||||
properties: Object
|
||||
required: string[]
|
||||
}
|
||||
schema: string | TSchema
|
||||
) => {
|
||||
if (
|
||||
typeof schema === 'object' &&
|
||||
@@ -70,15 +77,11 @@ const mapTypesResponse = (
|
||||
const responses: Record<string, OpenAPIV3.MediaTypeObject> = {}
|
||||
|
||||
for (const type of types) {
|
||||
// console.log(schema)
|
||||
|
||||
responses[type] = {
|
||||
schema:
|
||||
typeof schema === 'string'
|
||||
? {
|
||||
$ref: `#/components/schemas/${schema}`
|
||||
}
|
||||
: { ...(schema as any) }
|
||||
? { $ref: `#/components/schemas/${schema}` }
|
||||
: schema
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +109,8 @@ export const generateOperationId = (method: string, paths: string) => {
|
||||
|
||||
const cloneHook = <T>(hook: T) => {
|
||||
if (!hook) return
|
||||
|
||||
if (typeof hook === 'string') return hook
|
||||
if (Array.isArray(hook)) return [...hook]
|
||||
return { ...hook }
|
||||
}
|
||||
|
||||
@@ -121,9 +125,11 @@ export const registerSchemaPath = ({
|
||||
contentType?: string | string[]
|
||||
path: string
|
||||
method: HTTPMethod
|
||||
hook?: LocalHook<any, any, any, any, any, any, any>
|
||||
hook?: LocalHook<any, any, any, any, any, any>
|
||||
models: Record<string, TSchema>
|
||||
}) => {
|
||||
hook = cloneHook(hook)
|
||||
|
||||
const contentType = hook?.type ?? [
|
||||
'application/json',
|
||||
'multipart/form-data',
|
||||
@@ -145,118 +151,39 @@ export const registerSchemaPath = ({
|
||||
|
||||
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[]
|
||||
}
|
||||
|
||||
const value = responseSchema as TSchema;
|
||||
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
|
||||
)
|
||||
description: value.description ?? "",
|
||||
headers: value.headers,
|
||||
content: mapTypesResponse(contentTypes, value),
|
||||
links: value.links,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Object.entries(responseSchema as Record<string, TSchema>).forEach(
|
||||
Object.entries(responseSchema as Record<string, TSchema | string>).forEach(
|
||||
([key, value]) => {
|
||||
if (typeof value === 'string') {
|
||||
if (!models[value]) return
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const {
|
||||
type,
|
||||
properties,
|
||||
required,
|
||||
additionalProperties: _1,
|
||||
patternProperties: _2,
|
||||
...rest
|
||||
} = models[value] as TSchema & {
|
||||
type: string
|
||||
properties: Object
|
||||
required: string[]
|
||||
}
|
||||
|
||||
responseSchema[key] = {
|
||||
...rest,
|
||||
description: rest.description as any,
|
||||
content: mapTypesResponse(contentTypes, value)
|
||||
}
|
||||
} else {
|
||||
const {
|
||||
type,
|
||||
properties,
|
||||
required,
|
||||
additionalProperties,
|
||||
patternProperties,
|
||||
...rest
|
||||
} = value as typeof value & {
|
||||
type: string
|
||||
properties: Object
|
||||
required: string[]
|
||||
}
|
||||
|
||||
responseSchema[key] = {
|
||||
...rest,
|
||||
description: rest.description as any,
|
||||
content: mapTypesResponse(
|
||||
contentTypes,
|
||||
type === 'object' || type === 'array'
|
||||
? ({
|
||||
type,
|
||||
properties,
|
||||
patternProperties,
|
||||
items: value.items,
|
||||
required
|
||||
} as any)
|
||||
: value
|
||||
)
|
||||
}
|
||||
const model = typeof value === 'string' ? models[value] : value;
|
||||
if (!model) return;
|
||||
responseSchema[key] = {
|
||||
description: model.description ?? "",
|
||||
headers: model.headers,
|
||||
links: model.links,
|
||||
content: mapTypesResponse(contentTypes, model),
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
} 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[]
|
||||
}
|
||||
const value = models[responseSchema];
|
||||
if (!value) return
|
||||
|
||||
responseSchema = {
|
||||
// @ts-ignore
|
||||
'200': {
|
||||
...rest,
|
||||
content: mapTypesResponse(contentTypes, responseSchema)
|
||||
description: value.description ?? "",
|
||||
headers: value.headers,
|
||||
links: value.links,
|
||||
content: mapTypesResponse(contentTypes, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -270,33 +197,15 @@ export const registerSchemaPath = ({
|
||||
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),
|
||||
parameters,
|
||||
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
|
||||
requestBody: bodySchema ? {
|
||||
required: true,
|
||||
content: mapTypesResponse(contentTypes, bodySchema)
|
||||
} : undefined,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -276,4 +276,21 @@ describe('Swagger', () => {
|
||||
expect(response.paths['/invalid']).toBeUndefined();
|
||||
|
||||
})
|
||||
|
||||
it('should work with defined models', async () => {
|
||||
const app = new Elysia().use(swagger())
|
||||
.model({"test": t.Integer()})
|
||||
.get("/valid", 12, { response: { 200: "test" } });
|
||||
|
||||
await app.modules
|
||||
|
||||
const res = await app.handle(req('/swagger/json'))
|
||||
expect(res.status).toBe(200)
|
||||
const response = await res.json()
|
||||
expect(response.paths['/valid'].get.responses["200"].content["application/json"].schema.type)
|
||||
.toBe("integer");
|
||||
expect(response.components.schemas.test.type).toBe("integer");
|
||||
await SwaggerParser.validate(response).catch((err) => fail(err))
|
||||
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user