Skip to content

Commit 2f1df4f

Browse files
committed
🎉 feat: generator
1 parent d028a9d commit 2f1df4f

File tree

8 files changed

+285
-11
lines changed

8 files changed

+285
-11
lines changed

bun.lock

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"": {
55
"name": "@elysiajs/swagger",
66
"dependencies": {
7+
"@sinclair/typemap": "^0.10.1",
78
"openapi-types": "^12.1.3",
89
},
910
"devDependencies": {
@@ -163,6 +164,8 @@
163164

164165
"@sinclair/typebox": ["@sinclair/[email protected]", "", {}, "sha512-5HAV9exOMcXRUxo+9iYB5n09XxzCXnfy4VTNW4xnDv+FgjzAGY989C28BIdljKqmF+ZltUwujE3aossvcVtq6g=="],
165166

167+
"@sinclair/typemap": ["@sinclair/[email protected]", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.30", "valibot": "^1.0.0", "zod": "^3.24.1" } }, "sha512-UXR0fhu/n3c9B6lB+SLI5t1eVpt9i9CdDrp2TajRe3LbKiUhCTZN2kSfJhjPnpc3I59jMRIhgew7+0HlMi08mg=="],
168+
166169
"@tokenizer/inflate": ["@tokenizer/[email protected]", "", { "dependencies": { "debug": "^4.4.0", "fflate": "^0.8.2", "token-types": "^6.0.0" } }, "sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg=="],
167170

168171
"@tokenizer/token": ["@tokenizer/[email protected]", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
@@ -471,6 +474,8 @@
471474

472475
"uri-js": ["[email protected]", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
473476

477+
"valibot": ["[email protected]", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-Nk8lX30Qhu+9txPYTwM0cFlWLdPFsFr6LblzqIySfbZph9+BFsAHsNvHOymEviUepeIW6KFHzpX8TKhbptBXXw=="],
478+
474479
"webidl-conversions": ["[email protected]", "", {}, "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg=="],
475480

476481
"whatwg-url": ["[email protected]", "", { "dependencies": { "lodash.sortby": "^4.7.0", "tr46": "^1.0.1", "webidl-conversions": "^4.0.2" } }, "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg=="],

example/gen.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { Elysia, t } from 'elysia'
2+
import { openapi } from '../src/index'
3+
import { fromTypes } from '../src/gen'
4+
5+
export const app = new Elysia()
6+
.use(
7+
openapi({
8+
references: fromTypes('example/gen.ts')
9+
})
10+
)
11+
.get('/', { test: 'hello' as const })
12+
.post(
13+
'/json',
14+
({ body, status }) => (Math.random() > 0.5 ? status(418) : body),
15+
{
16+
body: t.Object({
17+
hello: t.String()
18+
})
19+
}
20+
)
21+
.get('/id/:id/name/:name', ({ params }) => params)
22+
.listen(3000)

example/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ const user = t.Object({
1515
})
1616
})
1717

18-
const app = new Elysia()
18+
export const app = new Elysia()
1919
.use(
2020
openapi({
2121
provider: 'scalar',
@@ -81,5 +81,5 @@ const app = new Elysia()
8181
}
8282
}
8383
)
84-
.get('/id/:id?/name/:name?', () => {})
84+
.get('/id/:id/name/:name', () => {})
8585
.listen(3000)

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@
8181
"typescript": "^5.9.2"
8282
},
8383
"dependencies": {
84+
"@sinclair/typemap": "^0.10.1",
8485
"openapi-types": "^12.1.3"
8586
}
8687
}

src/gen/index.ts

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import type { InternalRoute } from 'elysia'
2+
import {
3+
readFileSync,
4+
mkdirSync,
5+
writeFileSync,
6+
rmSync,
7+
existsSync,
8+
cpSync
9+
} from 'fs'
10+
import { TypeBox } from '@sinclair/typemap'
11+
12+
import { tmpdir } from 'os'
13+
import { join } from 'path'
14+
import { spawnSync } from 'child_process'
15+
import { AdditionalReference, AdditionalReferences } from '../types'
16+
17+
const matchRoute = /: Elysia<(.*)>/gs
18+
const matchStatus = /(\d{3}):/gs
19+
const wrapStatusInQuote = (value: string) => value.replace(matchStatus, '"$1":')
20+
21+
const exec = (command: string, cwd: string) =>
22+
spawnSync(command, {
23+
shell: true,
24+
cwd,
25+
stdio: 'inherit'
26+
})
27+
28+
interface OpenAPIGeneratorOptions {
29+
/**
30+
* Path to tsconfig.json
31+
* @default tsconfig.json
32+
*/
33+
tsconfigPath?: string
34+
35+
/**
36+
* Name of the Elysia instance
37+
*
38+
* If multiple instances are found,
39+
* instanceName should be provided
40+
*/
41+
instanceName?: string
42+
43+
/**
44+
* Project root directory
45+
*
46+
* @default process.cwd()
47+
*/
48+
projectRoot?: string
49+
}
50+
51+
/**
52+
* Auto generate OpenAPI schema from Elysia instance
53+
*
54+
* It's expected that this command should run in project root
55+
*
56+
* @experimental use at your own risk
57+
*/
58+
export const fromTypes =
59+
(
60+
/**
61+
* Path to file where Elysia instance is
62+
*
63+
* The path must export an Elysia instance
64+
*/
65+
targetFilePath: string,
66+
{
67+
tsconfigPath = 'tsconfig.json',
68+
instanceName,
69+
projectRoot = process.cwd()
70+
}: OpenAPIGeneratorOptions = {}
71+
) =>
72+
() => {
73+
if (!targetFilePath.endsWith('.ts') && !targetFilePath.endsWith('.tsx'))
74+
throw new Error('Only .ts files are supported')
75+
76+
const tmpRoot = join(tmpdir(), '.ElysiaAutoOpenAPI')
77+
78+
if (existsSync(tmpRoot))
79+
rmSync(tmpRoot, { recursive: true, force: true })
80+
mkdirSync(tmpRoot, { recursive: true })
81+
82+
const extendsRef = existsSync(join(projectRoot, 'tsconfig.json'))
83+
? `"extends": "${join(projectRoot, 'tsconfig.json')}",`
84+
: ''
85+
86+
if (!join(projectRoot, targetFilePath))
87+
throw new Error('Target file does not exist')
88+
89+
writeFileSync(
90+
join(tmpRoot, tsconfigPath),
91+
`{
92+
${extendsRef}
93+
"compilerOptions": {
94+
"lib": ["ESNext"],
95+
"module": "ESNext",
96+
"noEmit": false,
97+
"moduleResolution": "bundler",
98+
"skipLibCheck": true,
99+
"skipDefaultLibCheck": true,
100+
"emitDeclarationOnly": true,
101+
"outDir": "./dist"
102+
},
103+
"include": ["${join(projectRoot, targetFilePath)}"]
104+
}`
105+
)
106+
107+
exec(`tsc`, tmpRoot)
108+
109+
try {
110+
const declaration = readFileSync(
111+
join(
112+
tmpRoot,
113+
'dist',
114+
targetFilePath
115+
.replace(/.tsx$/, '.ts')
116+
.replace(/.ts$/, '.d.ts')
117+
),
118+
'utf8'
119+
)
120+
121+
// Check just in case of race-condition
122+
if (existsSync(tmpRoot))
123+
rmSync(tmpRoot, { recursive: true, force: true })
124+
125+
let instance = declaration.match(
126+
instanceName
127+
? new RegExp(`${instanceName}: Elysia<(.*)`, 'gs')
128+
: matchRoute
129+
)?.[0]
130+
131+
if (!instance) return
132+
133+
// Get 5th generic parameter
134+
// Elysia<'', {}, {}, {}, Routes>
135+
// ------------------------^
136+
// 1 2 3 4 5
137+
// We want the 4th one
138+
for (let i = 0; i < 3; i++)
139+
instance = instance.slice(instance.indexOf('}, {', 3))
140+
141+
const routesString =
142+
wrapStatusInQuote(instance).slice(
143+
3,
144+
instance.indexOf('}, {', 3)
145+
) + '}\n}\n'
146+
147+
const routes: AdditionalReference = {}
148+
149+
for (let route of routesString.slice(1).split('} & {')) {
150+
route = '{' + route + '}'
151+
let schema = TypeBox(route)
152+
153+
if (schema.type !== 'object') continue
154+
155+
const paths = []
156+
157+
while (true) {
158+
const keys = Object.keys(schema.properties)
159+
if (!keys.length || keys.length > 1) break
160+
161+
paths.push(keys[0])
162+
163+
schema = schema.properties[keys[0]] as any
164+
if (!schema?.properties) break
165+
}
166+
167+
const method = paths.pop()!
168+
const path = '/' + paths.join('/')
169+
schema = schema.properties
170+
171+
if (schema?.response?.type === 'object') {
172+
const responseSchema: Record<string, any> = {}
173+
174+
for (const key in schema.response.properties)
175+
responseSchema[key] = schema.response.properties[key]
176+
177+
schema.response = responseSchema
178+
}
179+
180+
if (!routes[path]) routes[path] = {}
181+
// @ts-ignore
182+
routes[path][method] = schema
183+
}
184+
185+
return routes
186+
} catch (error) {
187+
console.warn('Failed to generate OpenAPI schema')
188+
console.warn(error)
189+
190+
return
191+
}
192+
}

src/index.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ export const openapi = <
2626
documentation = {},
2727
exclude,
2828
swagger,
29-
scalar
29+
scalar,
30+
references
3031
}: ElysiaOpenAPIConfig<Enabled, Path, Provider> = {}) => {
3132
if (!enabled) return new Elysia({ name: '@elysiajs/openapi' })
3233

@@ -87,7 +88,7 @@ export const openapi = <
8788
const {
8889
paths,
8990
components: { schemas }
90-
} = toOpenAPISchema(app, exclude)
91+
} = toOpenAPISchema(app, exclude, references)
9192

9293
return (cachedSchema = {
9394
openapi: '3.0.3',

src/openapi.ts

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@ import type { HookContainer } from 'elysia/types'
44
import type { OpenAPIV3 } from 'openapi-types'
55
import type { TProperties } from '@sinclair/typebox'
66

7-
import type { ElysiaOpenAPIConfig } from './types'
7+
import type {
8+
AdditionalReference,
9+
AdditionalReferences,
10+
ElysiaOpenAPIConfig
11+
} from './types'
812

913
export const capitalize = (word: string) =>
1014
word.charAt(0).toUpperCase() + word.slice(1)
@@ -37,7 +41,7 @@ export const getPossiblePath = (path: string): string[] => {
3741
const optionalParams = path.match(optionalParamsRegex)
3842
if (!optionalParams) return [path]
3943

40-
const originalPath = path.replaceAll('?', '')
44+
const originalPath = path.replace(/\?/g, '')
4145
const paths = [originalPath]
4246

4347
for (let i = 0; i < optionalParams.length; i++) {
@@ -56,7 +60,8 @@ export const getPossiblePath = (path: string): string[] => {
5660
*/
5761
export function toOpenAPISchema(
5862
app: AnyElysia,
59-
exclude?: ElysiaOpenAPIConfig['exclude']
63+
exclude?: ElysiaOpenAPIConfig['exclude'],
64+
references?: AdditionalReferences
6065
) {
6166
const {
6267
methods: excludeMethods = ['OPTIONS'],
@@ -75,6 +80,16 @@ export function toOpenAPISchema(
7580
// @ts-ignore private property
7681
const routes = app.getGlobalRoutes()
7782

83+
if (references) {
84+
if (!Array.isArray(references)) references = [references]
85+
86+
for (let i = 0; i < references.length; i++) {
87+
const reference = references[i]
88+
89+
if (typeof reference === 'function') references[i] = reference()
90+
}
91+
}
92+
7893
for (const route of routes) {
7994
if (route.hooks?.detail?.hide) continue
8095

@@ -87,6 +102,27 @@ export function toOpenAPISchema(
87102
detail: Partial<OpenAPIV3.OperationObject>
88103
} = route.hooks ?? {}
89104

105+
if (references)
106+
for (const reference of references as AdditionalReference[]) {
107+
const refer = reference[route.path]?.[method]
108+
if (!refer) continue
109+
110+
if (!hooks.body && refer.body) hooks.body = refer.body
111+
if (!hooks.query && refer.query) hooks.query = refer.query
112+
if (!hooks.params && refer.params) hooks.params = refer.params
113+
if (!hooks.headers && refer.headers)
114+
hooks.headers = refer.headers
115+
if (!hooks.response && refer.response) {
116+
hooks.response = {}
117+
118+
for (const [status, schema] of Object.entries(
119+
refer.response
120+
))
121+
if (!hooks.response[status as any])
122+
hooks.response[status as any] = schema
123+
}
124+
}
125+
90126
if (
91127
excludeTags &&
92128
hooks.detail.tags?.some((tag) => excludeTags?.includes(tag))
@@ -107,10 +143,6 @@ export function toOpenAPISchema(
107143

108144
// Handle path parameters
109145
if (hooks.params) {
110-
// const pathParamNames =
111-
// route.path.match(/:([^/]+)/g)?.map((param) => param.slice(1)) ||
112-
// []
113-
114146
if (typeof hooks.params === 'string')
115147
hooks.params = toRef(hooks.params)
116148

0 commit comments

Comments
 (0)