spiceflow 1.0.0 → 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/README.md +147 -0
- package/dist/client/index.d.ts +4 -3
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +5 -5
- package/dist/client/index.js.map +1 -1
- package/dist/client/types.d.ts +4 -5
- package/dist/client/types.d.ts.map +1 -1
- package/dist/client/ws.d.ts +4 -4
- package/dist/client/ws.d.ts.map +1 -1
- package/dist/client/ws.js.map +1 -1
- package/dist/client.test.js +9 -8
- package/dist/client.test.js.map +1 -1
- package/dist/elysia-fork/error.d.ts +5 -65
- package/dist/elysia-fork/error.d.ts.map +1 -1
- package/dist/elysia-fork/error.js +2 -2
- package/dist/elysia-fork/error.js.map +1 -1
- package/dist/elysia-fork/types.d.ts +27 -116
- package/dist/elysia-fork/types.d.ts.map +1 -1
- package/dist/elysia-fork/types.js +1 -2
- package/dist/elysia-fork/types.js.map +1 -1
- package/dist/elysia-fork/utils.d.ts +1 -62
- package/dist/elysia-fork/utils.d.ts.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/openapi.d.ts +68 -0
- package/dist/openapi.d.ts.map +1 -0
- package/dist/openapi.js +250 -0
- package/dist/openapi.js.map +1 -0
- package/dist/spiceflow.d.ts +48 -52
- package/dist/spiceflow.d.ts.map +1 -1
- package/dist/spiceflow.js +148 -87
- package/dist/spiceflow.js.map +1 -1
- package/dist/spiceflow.test.js +80 -43
- package/dist/spiceflow.test.js.map +1 -1
- package/dist/stream.test.js +14 -14
- package/dist/stream.test.js.map +1 -1
- package/dist/zod.test.d.ts +2 -0
- package/dist/zod.test.d.ts.map +1 -0
- package/dist/zod.test.js +59 -0
- package/dist/zod.test.js.map +1 -0
- package/package.json +7 -4
- package/src/client/index.ts +10 -8
- package/src/client/types.ts +4 -4
- package/src/client/ws.ts +4 -4
- package/src/client.test.ts +9 -8
- package/src/elysia-fork/context.ts +2 -2
- package/src/elysia-fork/error.ts +3 -3
- package/src/elysia-fork/types.ts +108 -284
- package/src/index.ts +2 -0
- package/src/openapi.ts +426 -0
- package/src/spiceflow.test.ts +117 -64
- package/src/spiceflow.ts +261 -179
- package/src/stream.test.ts +14 -14
- package/src/zod.test.ts +71 -0
package/src/index.ts
ADDED
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
|
+
}
|