spiceflow 1.0.1 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/openapi.ts ADDED
@@ -0,0 +1,426 @@
1
+ /* eslint-disable @typescript-eslint/ban-ts-comment */
2
+ import { JSONSchemaType } from 'ajv'
3
+ import { InternalRoute, isZodSchema, Spiceflow } from './spiceflow'
4
+ import { ZodType } from 'zod'
5
+
6
+ import type { OpenAPIV3 } from 'openapi-types'
7
+
8
+ let excludeMethods = ['OPTIONS']
9
+ /* eslint-disable @typescript-eslint/ban-ts-comment */
10
+ /* eslint-disable @typescript-eslint/no-unused-vars */
11
+ import type { HTTPMethod, LocalHook, TypeSchema } from './elysia-fork/types'
12
+
13
+ import { Kind, type TSchema } from '@sinclair/typebox'
14
+
15
+ import deepClone from 'lodash.clonedeep'
16
+ import { z } from 'zod'
17
+ import zodToJsonSchema from 'zod-to-json-schema'
18
+
19
+ export const toOpenAPIPath = (path: string) =>
20
+ path
21
+ .split('/')
22
+ .map((x) => {
23
+ if (x.startsWith(':')) {
24
+ x = x.slice(1, x.length)
25
+ if (x.endsWith('?')) x = x.slice(0, -1)
26
+ x = `{${x}}`
27
+ }
28
+
29
+ return x
30
+ })
31
+ .join('/')
32
+
33
+ export const mapProperties = (
34
+ name: string,
35
+ schema: TypeSchema | string | undefined,
36
+ models: Record<string, TypeSchema>,
37
+ ) => {
38
+ if (schema === undefined) return []
39
+
40
+ if (typeof schema === 'string')
41
+ if (schema in models) schema = models[schema]
42
+ else throw new Error(`Can't find model ${schema}`)
43
+
44
+ let jsonSchema = getJsonSchema(schema)
45
+
46
+ return Object.entries(jsonSchema?.properties ?? []).map(([key, value]) => {
47
+ const {
48
+ type: valueType = undefined,
49
+ description,
50
+ examples,
51
+ ...schemaKeywords
52
+ } = value as any
53
+ return {
54
+ // @ts-ignore
55
+ description,
56
+ examples,
57
+ schema: { type: valueType, ...schemaKeywords },
58
+ in: name,
59
+ name: key,
60
+
61
+ required: jsonSchema!.required?.includes(key) ?? false,
62
+ }
63
+ })
64
+ }
65
+
66
+ const mapTypesResponse = (
67
+ types: string[],
68
+ schema:
69
+ | string
70
+ | {
71
+ type: string
72
+ properties: Object
73
+ required: string[]
74
+ },
75
+ ) => {
76
+ if (
77
+ typeof schema === 'object' &&
78
+ ['void', 'undefined', 'null'].includes(schema.type)
79
+ )
80
+ return
81
+
82
+ const responses: Record<string, OpenAPIV3.MediaTypeObject> = {}
83
+
84
+ for (const type of types)
85
+ responses[type] = {
86
+ schema:
87
+ typeof schema === 'string'
88
+ ? {
89
+ $ref: `#/components/schemas/${schema}`,
90
+ }
91
+ : { ...(schema as any) },
92
+ }
93
+
94
+ return responses
95
+ }
96
+
97
+ export const capitalize = (word: string) =>
98
+ word.charAt(0).toUpperCase() + word.slice(1)
99
+
100
+ export const generateOperationId = (method: string, paths: string) => {
101
+ let operationId = method.toLowerCase()
102
+
103
+ if (paths === '/') return operationId + 'Index'
104
+
105
+ for (const path of paths.split('/')) {
106
+ if (path.charCodeAt(0) === 123) {
107
+ operationId += 'By' + capitalize(path.slice(1, -1))
108
+ } else {
109
+ operationId += capitalize(path)
110
+ }
111
+ }
112
+
113
+ return operationId
114
+ }
115
+
116
+ export const registerSchemaPath = ({
117
+ schema,
118
+ path,
119
+ method,
120
+ hook,
121
+ models,
122
+ }: {
123
+ schema: Partial<OpenAPIV3.PathsObject>
124
+ contentType?: string | string[]
125
+ path: string
126
+ method: HTTPMethod
127
+ hook?: LocalHook<any, any, any, any, any, any, any>
128
+ models: Record<string, TypeSchema>
129
+ }) => {
130
+ if (hook) hook = deepClone(hook)
131
+
132
+ // TODO if a route uses an async generator, add text/event-stream. if a roue does not add an explicit schema, use all possible content types
133
+ const contentType = hook?.type ?? [
134
+ 'application/json',
135
+ // 'multipart/form-data',
136
+ // 'text/plain',
137
+ ]
138
+
139
+ path = toOpenAPIPath(path)
140
+
141
+ const contentTypes =
142
+ typeof contentType === 'string'
143
+ ? [contentType]
144
+ : contentType ?? ['application/json']
145
+
146
+ const bodySchema = hook?.body
147
+ const paramsSchema = hook?.params
148
+ // const headerSchema = hook?.headers
149
+ const querySchema = hook?.query
150
+ let responseSchema = hook?.response as unknown as OpenAPIV3.ResponsesObject
151
+
152
+ if (typeof responseSchema === 'object') {
153
+ if (Kind in responseSchema) {
154
+ const {
155
+ type,
156
+ properties,
157
+ required,
158
+ additionalProperties,
159
+ patternProperties,
160
+ ...rest
161
+ } = responseSchema as typeof responseSchema & {
162
+ type: string
163
+ properties: Object
164
+ required: string[]
165
+ }
166
+
167
+ responseSchema = {
168
+ '200': {
169
+ ...rest,
170
+ description: rest.description as any,
171
+ content: mapTypesResponse(
172
+ contentTypes,
173
+ type === 'object' || type === 'array'
174
+ ? ({
175
+ type,
176
+ properties,
177
+ patternProperties,
178
+ items: responseSchema.items,
179
+ required,
180
+ } as any)
181
+ : responseSchema,
182
+ ),
183
+ },
184
+ }
185
+ } else {
186
+ Object.entries(responseSchema as Record<string, TSchema>).forEach(
187
+ ([key, value]) => {
188
+ if (typeof value === 'string') {
189
+ if (!models[value]) return
190
+
191
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
192
+ const {
193
+ type,
194
+ properties,
195
+ required,
196
+ additionalProperties: _1,
197
+ patternProperties: _2,
198
+ ...rest
199
+ } = models[value] as TSchema & {
200
+ type: string
201
+ properties: Object
202
+ required: string[]
203
+ }
204
+
205
+ responseSchema[key] = {
206
+ ...rest,
207
+ description: rest.description as any,
208
+ content: mapTypesResponse(contentTypes, value),
209
+ }
210
+ } else {
211
+ const {
212
+ type,
213
+ properties,
214
+ required,
215
+ additionalProperties,
216
+ patternProperties,
217
+ ...rest
218
+ } = value as typeof value & {
219
+ type: string
220
+ properties: Object
221
+ required: string[]
222
+ }
223
+
224
+ responseSchema[key] = {
225
+ ...rest,
226
+ description: rest.description as any,
227
+ content: mapTypesResponse(
228
+ contentTypes,
229
+ type === 'object' || type === 'array'
230
+ ? ({
231
+ type,
232
+ properties,
233
+ patternProperties,
234
+ items: value.items,
235
+ required,
236
+ } as any)
237
+ : value,
238
+ ),
239
+ }
240
+ }
241
+ },
242
+ )
243
+ }
244
+ } else if (typeof responseSchema === 'string') {
245
+ if (!(responseSchema in models)) return
246
+
247
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
248
+ const {
249
+ type,
250
+ properties,
251
+ required,
252
+ additionalProperties: _1,
253
+ patternProperties: _2,
254
+ ...rest
255
+ } = models[responseSchema] as TSchema & {
256
+ type: string
257
+ properties: Object
258
+ required: string[]
259
+ }
260
+
261
+ responseSchema = {
262
+ // @ts-ignore
263
+ '200': {
264
+ ...rest,
265
+ content: mapTypesResponse(contentTypes, responseSchema),
266
+ },
267
+ }
268
+ }
269
+
270
+ const parameters = [
271
+ // ...mapProperties('header', headerSchema, models),
272
+ ...mapProperties('path', paramsSchema, models),
273
+ ...mapProperties('query', querySchema, models),
274
+ ]
275
+
276
+ schema[path] = {
277
+ ...(schema[path] ? schema[path] : {}),
278
+ [method.toLowerCase()]: {
279
+ ...((paramsSchema || querySchema || bodySchema
280
+ ? ({ parameters } as any)
281
+ : {}) satisfies OpenAPIV3.ParameterObject),
282
+ ...(responseSchema
283
+ ? {
284
+ responses: responseSchema,
285
+ }
286
+ : {}),
287
+ operationId:
288
+ hook?.detail?.operationId ?? generateOperationId(method, path),
289
+ ...hook?.detail,
290
+ ...(bodySchema
291
+ ? {
292
+ requestBody: {
293
+ required: true,
294
+ content: mapTypesResponse(
295
+ contentTypes,
296
+ typeof bodySchema === 'string'
297
+ ? {
298
+ $ref: `#/components/schemas/${bodySchema}`,
299
+ }
300
+ : (bodySchema as any),
301
+ ),
302
+ },
303
+ }
304
+ : null),
305
+ } satisfies OpenAPIV3.OperationObject,
306
+ }
307
+ }
308
+
309
+ /**
310
+ * Plugin for [elysia](https://github.com/elysiajs/elysia) that auto-generate Swagger page.
311
+ *
312
+ * @see https://github.com/elysiajs/elysia-swagger
313
+ */
314
+ export const openapi = <Path extends string = '/openapi'>({
315
+ path,
316
+ documentation = {},
317
+ }: {
318
+ path: Path
319
+ /**
320
+ * Customize Swagger config, refers to Swagger 2.0 config
321
+ *
322
+ * @see https://swagger.io/specification/v2/
323
+ */
324
+ documentation?: Omit<
325
+ Partial<OpenAPIV3.Document>,
326
+ | 'x-express-openapi-additional-middleware'
327
+ | 'x-express-openapi-validation-strict'
328
+ >
329
+ }) => {
330
+ const schema = {}
331
+ let totalRoutes = 0
332
+
333
+ const relativePath = path.startsWith('/') ? path.slice(1) : path
334
+
335
+ const app = new Spiceflow({ name: 'openapi' }).get(path, ({}) => {
336
+ let routes = app.getAllRoutes()
337
+ if (routes.length !== totalRoutes) {
338
+ const ALLOWED_METHODS = [
339
+ 'GET',
340
+ 'PUT',
341
+ 'POST',
342
+ 'DELETE',
343
+ 'OPTIONS',
344
+ 'HEAD',
345
+ 'PATCH',
346
+ 'TRACE',
347
+ ]
348
+ totalRoutes = routes.length
349
+
350
+ routes.forEach((route: InternalRoute) => {
351
+ if (route.hooks?.detail?.hide === true) return
352
+ // TODO: route.hooks?.detail?.hide !== false add ability to hide: false to prevent excluding
353
+ if (excludeMethods.includes(route.method)) return
354
+ if (
355
+ ALLOWED_METHODS.includes(route.method) === false &&
356
+ route.method !== 'ALL'
357
+ )
358
+ return
359
+
360
+ if (route.method === 'ALL') {
361
+ ALLOWED_METHODS.forEach((method) => {
362
+ registerSchemaPath({
363
+ schema,
364
+ hook: route.hooks,
365
+ method,
366
+ path: route.path,
367
+ // @ts-ignore
368
+ models: app.definitions?.type,
369
+ contentType: route.hooks?.type,
370
+ })
371
+ })
372
+ return
373
+ }
374
+
375
+ registerSchemaPath({
376
+ schema,
377
+ hook: route.hooks,
378
+ method: route.method,
379
+ path: route.path,
380
+ // @ts-ignore
381
+ models: app.definitions?.type,
382
+ contentType: route.hooks?.type,
383
+ })
384
+ })
385
+ }
386
+
387
+ return {
388
+ openapi: '3.0.3',
389
+ ...{
390
+ ...documentation,
391
+ // tags: documentation.tags?.filter(
392
+ // (tag) => !excludeTags?.includes(tag?.name),
393
+ // ),
394
+ info: {
395
+ title: 'Elysia Documentation',
396
+ description: 'Development documentation',
397
+ version: '0.0.0',
398
+ ...documentation.info,
399
+ },
400
+ },
401
+ paths: {
402
+ ...schema,
403
+ ...documentation.paths,
404
+ },
405
+ components: {
406
+ ...documentation.components,
407
+ schemas: {
408
+ // @ts-ignore
409
+ ...app.definitions?.type,
410
+ ...documentation.components?.schemas,
411
+ },
412
+ },
413
+ } satisfies OpenAPIV3.Document
414
+ })
415
+
416
+ return app
417
+ }
418
+
419
+ function getJsonSchema(schema: TypeSchema): JSONSchemaType<any> {
420
+ if (isZodSchema(schema)) {
421
+ let jsonSchema = zodToJsonSchema(schema, {})
422
+ return jsonSchema as any
423
+ }
424
+
425
+ return schema as any
426
+ }
@@ -1,27 +1,27 @@
1
1
  import { test, describe, expect } from 'vitest'
2
2
  import { Type } from '@sinclair/typebox'
3
- import { Spiceflow } from './spiceflow'
3
+ import { bfs, Spiceflow } from './spiceflow'
4
4
 
5
5
  test('works', async () => {
6
6
  const res = await new Spiceflow()
7
7
  .post('/xxx', () => 'hi')
8
8
  .handle(new Request('http://localhost/xxx', { method: 'POST' }))
9
9
  expect(res.status).toBe(200)
10
- expect(await res.text()).toBe(JSON.stringify('hi'))
10
+ expect(await res.json()).toEqual('hi')
11
11
  })
12
12
  test('dynamic route', async () => {
13
13
  const res = await new Spiceflow()
14
14
  .post('/ids/:id', () => 'hi')
15
15
  .handle(new Request('http://localhost/ids/xxx', { method: 'POST' }))
16
16
  expect(res.status).toBe(200)
17
- expect(await res.text()).toBe(JSON.stringify('hi'))
17
+ expect(await res.json()).toEqual('hi')
18
18
  })
19
19
  test('GET dynamic route', async () => {
20
20
  const res = await new Spiceflow()
21
21
  .get('/ids/:id', () => 'hi')
22
22
  .handle(new Request('http://localhost/ids/xxx', { method: 'GET' }))
23
23
  expect(res.status).toBe(200)
24
- expect(await res.text()).toBe(JSON.stringify('hi'))
24
+ expect(await res.json()).toEqual('hi')
25
25
  })
26
26
 
27
27
  test('missing route is not found', async () => {
@@ -57,13 +57,13 @@ test('body is parsed as json', async () => {
57
57
  new Request('http://localhost/post', {
58
58
  method: 'POST',
59
59
  headers: {
60
- 'content-type': 'application/json'
60
+ 'content-type': 'application/json',
61
61
  },
62
- body: JSON.stringify({ name: 'John' })
63
- })
62
+ body: JSON.stringify({ name: 'John' }),
63
+ }),
64
64
  )
65
65
  expect(res.status).toBe(200)
66
- expect(await res.text()).toBe(JSON.stringify({ name: 'John' }))
66
+ expect(await res.json()).toEqual({ name: 'John' })
67
67
  })
68
68
 
69
69
  test('validate body works, request success', async () => {
@@ -78,18 +78,18 @@ test('validate body works, request success', async () => {
78
78
  },
79
79
  {
80
80
  body: Type.Object({
81
- name: Type.String()
82
- })
83
- }
81
+ name: Type.String(),
82
+ }),
83
+ },
84
84
  )
85
85
  .handle(
86
86
  new Request('http://localhost/post', {
87
87
  method: 'POST',
88
88
  headers: {
89
- 'content-type': 'application/json'
89
+ 'content-type': 'application/json',
90
90
  },
91
- body: JSON.stringify({ name: 'John' })
92
- })
91
+ body: JSON.stringify({ name: 'John' }),
92
+ }),
93
93
  )
94
94
  expect(res.status).toBe(200)
95
95
  expect(await res.text()).toMatchInlineSnapshot(`""ok""`)
@@ -107,22 +107,22 @@ test('validate body works, request fails', async () => {
107
107
  {
108
108
  body: Type.Object({
109
109
  name: Type.String(),
110
- requiredField: Type.String()
111
- })
112
- }
110
+ requiredField: Type.String(),
111
+ }),
112
+ },
113
113
  )
114
114
  .handle(
115
115
  new Request('http://localhost/post', {
116
116
  method: 'POST',
117
117
  headers: {
118
- 'content-type': 'application/json'
118
+ 'content-type': 'application/json',
119
119
  },
120
- body: JSON.stringify({ name: 'John' })
121
- })
120
+ body: JSON.stringify({ name: 'John' }),
121
+ }),
122
122
  )
123
123
  expect(res.status).toBe(400)
124
124
  expect(await res.text()).toMatchInlineSnapshot(
125
- `"data must have required property 'requiredField'"`
125
+ `"data must have required property 'requiredField'"`,
126
126
  )
127
127
  })
128
128
 
@@ -163,43 +163,96 @@ test('basPath works', async () => {
163
163
  .get('/ids/:id', () => 'hi')
164
164
  .handle(new Request('http://localhost/one/ids/xxx', { method: 'GET' }))
165
165
  expect(res.status).toBe(200)
166
- expect(await res.text()).toBe(JSON.stringify('hi'))
166
+ expect(await res.json()).toEqual('hi')
167
167
  })
168
168
 
169
+ test('basPath works with use', async () => {
170
+ let app = new Spiceflow({ basePath: '/one' }).use(
171
+ new Spiceflow({})
172
+ .get('/two', () => 'hi')
173
+ .use(
174
+ new Spiceflow({ basePath: '/three' }).get('/four', () => 'hi'),
175
+ ),
176
+ )
177
+ {
178
+ const res = await app.handle(
179
+ new Request('http://localhost/one/two', { method: 'GET' }),
180
+ )
181
+
182
+ expect(res.status).toBe(200)
183
+ expect(await res.json()).toEqual('hi')
184
+ }
185
+ {
186
+ const res = await app.handle(
187
+ new Request('http://localhost/one/three/four', { method: 'GET' }),
188
+ )
189
+ expect(res.status).toBe(200)
190
+ expect(await res.json()).toEqual('hi')
191
+ }
192
+ })
193
+
194
+ test('getRouteAndParents', async () => {
195
+ let app = new Spiceflow({ basePath: '/one' })
196
+ .get('/ids/:id', () => 'hi')
197
+ .use(
198
+ new Spiceflow({ basePath: '/two' }).use(
199
+ new Spiceflow({ basePath: '/three' }).get('/four', () => 'hi'),
200
+ ),
201
+ )
202
+
203
+ let routers = bfs(app['routerTree'])
204
+ let last = routers[routers.length - 1]
205
+
206
+ expect(app['getRouteAndParents'](last).map((x) => x.prefix))
207
+ .toMatchInlineSnapshot(`
208
+ [
209
+ "/three",
210
+ "/two",
211
+ "/one",
212
+ ]
213
+ `)
214
+ })
169
215
  test('use with 2 basPath works', async () => {
170
216
  let oneOnReq = false
171
217
  let twoOnReq = false
218
+ let onReqCalled: string[] = []
172
219
  const app = await new Spiceflow()
220
+ .onRequest(({ request }) => {
221
+ onReqCalled.push('root')
222
+ })
173
223
  .use(
174
224
  new Spiceflow({ basePath: '/one' })
175
225
  .onRequest(({ request }) => {
176
226
  oneOnReq = true
227
+ onReqCalled.push('one')
177
228
  })
178
- .get('/ids/:id', ({ params }) => params.id)
229
+ .get('/ids/:id', ({ params }) => params.id),
179
230
  )
180
231
  .use(
181
232
  new Spiceflow({ basePath: '/two' })
182
233
  .onRequest((c) => {
183
234
  twoOnReq = true
235
+ onReqCalled.push('two')
184
236
  })
185
- .get('/ids/:id', ({ params }) => params.id)
237
+ .get('/ids/:id', ({ params }) => params.id),
186
238
  )
187
239
 
188
240
  {
189
241
  const res = await app.handle(
190
- new Request('http://localhost/one/ids/one')
242
+ new Request('http://localhost/one/ids/one'),
191
243
  )
192
244
  expect(res.status).toBe(200)
193
245
 
194
- expect(await res.text()).toBe(JSON.stringify('one'))
246
+ expect(await res.json()).toEqual('one')
195
247
  }
248
+ expect(onReqCalled).toEqual(['root', 'one'])
196
249
  {
197
250
  const res = await app.handle(
198
- new Request('http://localhost/two/ids/two')
251
+ new Request('http://localhost/two/ids/two'),
199
252
  )
200
253
  expect(res.status).toBe(200)
201
254
 
202
- expect(await res.text()).toBe(JSON.stringify('two'))
255
+ expect(await res.json()).toEqual('two')
203
256
  }
204
257
  expect(oneOnReq).toBe(true)
205
258
  expect(twoOnReq).toBe(true)
@@ -210,31 +263,31 @@ test('use with nested basPath works', async () => {
210
263
  .use(
211
264
  new Spiceflow({ basePath: '/one' }).get(
212
265
  '/ids/:id',
213
- ({ params }) => params.id
214
- )
266
+ ({ params }) => params.id,
267
+ ),
215
268
  )
216
269
  .use(
217
270
  new Spiceflow({ basePath: '/two' }).use(
218
271
  new Spiceflow({ basePath: '/nested' }).get(
219
272
  '/ids/:id',
220
- ({ params }) => params.id
221
- )
222
- )
273
+ ({ params }) => params.id,
274
+ ),
275
+ ),
223
276
  )
224
277
  {
225
278
  const res = await app.handle(
226
- new Request('http://localhost/zero/one/ids/one')
279
+ new Request('http://localhost/zero/one/ids/one'),
227
280
  )
228
281
  expect(res.status).toBe(200)
229
- expect(await res.text()).toBe(JSON.stringify('one'))
282
+ expect(await res.json()).toEqual('one')
230
283
  }
231
284
 
232
285
  {
233
286
  const res = await app.handle(
234
- new Request('http://localhost/zero/two/nested/ids/nested')
287
+ new Request('http://localhost/zero/two/nested/ids/nested'),
235
288
  )
236
289
  expect(res.status).toBe(200)
237
- expect(await res.text()).toBe(JSON.stringify('nested'))
290
+ expect(await res.json()).toEqual('nested')
238
291
  }
239
292
  })
240
293
 
@@ -273,18 +326,18 @@ test('errors inside basPath works', async () => {
273
326
  })
274
327
  .get('/ids/:id', ({ params }) => {
275
328
  throw new Error('error message')
276
- })
277
- )
329
+ }),
330
+ ),
278
331
  )
279
332
 
280
333
  {
281
334
  const res = await app.handle(
282
- new Request('http://localhost/zero/two/nested/ids/nested')
335
+ new Request('http://localhost/zero/two/nested/ids/nested'),
283
336
  )
284
- expect(onErrorTriggered).toEqual(['nested', 'two', 'root'])
285
- expect(onReqTriggered).toEqual(['nested', 'two', 'root'])
337
+ expect(onErrorTriggered).toEqual(['root', 'two', 'nested'])
338
+ expect(onReqTriggered).toEqual(['root', 'two', 'nested'])
286
339
  expect(res.status).toBe(500)
287
340
  expect(await res.text()).toBe('error message')
288
- // expect(await res.text()).toBe(JSON.stringify('nested'))
341
+ // expect(await res.json()).toEqual('nested'))
289
342
  }
290
343
  })